Formatting changes are tracked like insertions and deletions: - Model: runs and paragraph marks carry the formatting before a tracked change (CharProps::fmt_change), paragraphs their old properties (ParaProps::fmt_change) and a list numbering change; tables, rows, cells and sections their old properties. - DOCX: w:rPrChange (runs and paragraph marks), w:pPrChange, w:numberingChange, w:tblPrChange, w:trPrChange, w:tcPrChange and w:sectPrChange are read and written back, each last in its parent (ECMA-376 17.13.5). Nested changes are ignored; ids are renumbered. - Recording: with Track Changes and Track Formatting on, Session::run compares the paragraphs a command changed with the document before it, so every formatting command records without per-command code. An author's later changes keep the formatting before the first one; formatting set back to the original stops being a change. - Accept/Reject (at the caret, selection, all) keep or restore the formatting; Next/Previous and the Reviewing Pane list format changes with a "Formatted: Bold, Size: 14" description. - Markup: change bars beside formatted lines and "Formatted: ..." balloons in the markup area, per Track Changes Options. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4c6dd898c2
commit
972df1e0c7
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
|
|
@ -43,7 +43,7 @@ discounts and the user evidence (120 issues from outside users, 4 praise, 0 "swi
|
|||
merge, open and save .docx) work end to end and ship as signed builds on every desktop platform
|
||||
and the web. **Why not beta:** DOCX has never been tested on real-world files; charts, SmartArt and
|
||||
OLE objects are dropped silently; pagination differs from Word (no Aptos-metric font, no column
|
||||
balancing or footnote continuation); formatting revisions are lost; there is open field evidence of
|
||||
balancing or footnote continuation); move tracking and content controls are lost; there is open field evidence of
|
||||
a startup crash and several hangs; no native printing. Gaps #1–#7 in [`docs/gaps.md`](docs/gaps.md)
|
||||
are the beta list. All six core workflows pass the
|
||||
[alpha gate](docs/roadmap.md#alpha-gate-core-workflows) end to end on macOS, with save and reopen;
|
||||
|
|
@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars |
|
||||
| 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources |
|
||||
| 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) |
|
||||
| 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) |
|
||||
|
|
|
|||
|
|
@ -315,6 +315,37 @@ pub struct CharProps {
|
|||
/// Tracked deletion: index into `Document::revisions`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub del: Option<u32>,
|
||||
/// Tracked formatting change (`w:rPrChange`): the formatting before it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CharProps>>>,
|
||||
}
|
||||
|
||||
/// A tracked formatting change (ECMA-376 §17.13.5: `w:rPrChange`, `w:pPrChange`,
|
||||
/// `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:sectPrChange`): the properties as they
|
||||
/// were before the change, and the revision (author, date) that made it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PropChange<T> {
|
||||
/// Index into `Document::revisions`.
|
||||
pub rev: u32,
|
||||
/// The properties before the change (formatting only: no revision or link marks).
|
||||
pub old: T,
|
||||
}
|
||||
|
||||
impl<T> PropChange<T> {
|
||||
pub fn boxed(rev: u32, old: T) -> Option<Box<PropChange<T>>> {
|
||||
Some(Box::new(PropChange { rev, old }))
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked change of a paragraph's list numbering (`w:numberingChange`, kept for round trip):
|
||||
/// `original` is the number as it was shown before the change.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct NumChange {
|
||||
/// Index into `Document::revisions`.
|
||||
pub rev: u32,
|
||||
pub original: String,
|
||||
}
|
||||
|
||||
macro_rules! overlay_fields {
|
||||
|
|
@ -334,7 +365,7 @@ impl CharProps {
|
|||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del);
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change);
|
||||
if patch.size.is_some() && patch.size_cs.is_none() {
|
||||
self.size_cs = None;
|
||||
}
|
||||
|
|
@ -355,7 +386,11 @@ impl CharProps {
|
|||
/// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style
|
||||
/// is cleared too, as Word's Clear Formatting does).
|
||||
pub fn cleared(&self) -> CharProps {
|
||||
CharProps { link: self.link.clone(), ins: self.ins, del: self.del, ..Default::default() }
|
||||
CharProps { link: self.link.clone(), ins: self.ins, del: self.del, fmt_change: self.fmt_change.clone(), ..Default::default() }
|
||||
}
|
||||
/// Just the formatting: without the link, revision marks and tracked formatting change.
|
||||
pub fn formatting(&self) -> CharProps {
|
||||
CharProps { link: None, ins: None, del: None, fmt_change: None, ..self.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -643,6 +678,12 @@ pub struct ParaProps {
|
|||
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
|
||||
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_dn: Option<bool>,
|
||||
/// Tracked change of the list numbering (`w:numberingChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_change: Option<Box<NumChange>>,
|
||||
/// Tracked formatting change (`w:pPrChange`): the paragraph properties before it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<ParaProps>>>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
|
|
@ -658,6 +699,10 @@ impl ParaProps {
|
|||
pub fn is_empty(&self) -> bool {
|
||||
*self == ParaProps::default()
|
||||
}
|
||||
/// Just the formatting: without the tracked changes.
|
||||
pub fn formatting(&self) -> ParaProps {
|
||||
ParaProps { num_change: None, fmt_change: None, ..self.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
|
|
@ -739,6 +784,9 @@ pub struct TableProps {
|
|||
/// Floating placement (`w:tblpPr`); `None` for a table in the text flow.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub float: Option<TableFloat>,
|
||||
/// Tracked formatting change (`w:tblPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<TableProps>>>,
|
||||
}
|
||||
|
||||
/// Where a floating table sits; the text after it wraps around it.
|
||||
|
|
@ -804,6 +852,9 @@ pub struct RowProps {
|
|||
pub header: bool,
|
||||
/// Don't let the row break across pages.
|
||||
pub cant_split: bool,
|
||||
/// Tracked formatting change (`w:trPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<RowProps>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
|
|
@ -836,6 +887,9 @@ pub struct CellProps {
|
|||
/// Which way the cell's text runs.
|
||||
pub text_direction: TextDirection,
|
||||
pub no_wrap: bool,
|
||||
/// Tracked formatting change (`w:tcPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CellProps>>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ pub struct ResolvedChar {
|
|||
pub link: Option<String>,
|
||||
pub ins: Option<u32>,
|
||||
pub del: Option<u32>,
|
||||
/// The revision of a tracked formatting change (`w:rPrChange`) of the run.
|
||||
pub fmt: Option<u32>,
|
||||
pub no_proof: bool,
|
||||
pub lang: Option<String>,
|
||||
/// Right-to-left run (`w:rtl`).
|
||||
|
|
@ -81,6 +83,7 @@ impl ResolvedChar {
|
|||
link: c.link.clone(),
|
||||
ins: c.ins,
|
||||
del: c.del,
|
||||
fmt: c.fmt_change.as_ref().map(|f| f.rev),
|
||||
no_proof: c.no_proof.unwrap_or(false),
|
||||
lang: c.lang.clone(),
|
||||
rtl: c.rtl.unwrap_or(false),
|
||||
|
|
|
|||
|
|
@ -296,6 +296,9 @@ pub struct SectionProps {
|
|||
pub page_borders: Option<Borders>,
|
||||
/// Text direction / bidi section.
|
||||
pub rtl: bool,
|
||||
/// Tracked change of the section's properties (`w:sectPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<crate::props::PropChange<SectionProps>>>,
|
||||
}
|
||||
|
||||
impl Default for SectionProps {
|
||||
|
|
@ -323,6 +326,7 @@ impl Default for SectionProps {
|
|||
valign: VAlign::Top,
|
||||
page_borders: None,
|
||||
rtl: false,
|
||||
fmt_change: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,7 +244,16 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
|||
}
|
||||
}
|
||||
}
|
||||
Row { props: RowProps { height: info.height, height_rule: info.height_rule, header: info.header, cant_split: info.cant_split }, cells }
|
||||
Row {
|
||||
props: RowProps {
|
||||
height: info.height,
|
||||
height_rule: info.height_rule,
|
||||
header: info.header,
|
||||
cant_split: info.cant_split,
|
||||
fmt_change: None,
|
||||
},
|
||||
cells,
|
||||
}
|
||||
}
|
||||
fn flush_table(out: &mut Blocks, rows: &mut Vec<Row>, cells: &mut Vec<Cell>, cell: &mut Blocks, grid: &[f32], props: &TableProps, nested: bool) {
|
||||
if !cells.is_empty() || !cell.is_empty() {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::sync::Arc;
|
|||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind};
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{CharProps, Rgb};
|
||||
use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb};
|
||||
use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block};
|
||||
|
||||
|
|
@ -250,6 +250,19 @@ impl Reader<'_> {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
para.mark.fmt_change = self.rpr_change(rpr);
|
||||
}
|
||||
// Tracked formatting changes (§17.13.5.29, §17.13.5.19): the paragraph properties
|
||||
// before the change, and a change of the list numbering.
|
||||
if let Some(ch) = ppr.child("w:pPrChange") {
|
||||
let old = ch.child("w:pPr").map(|o| self.pc.ppr(o).0.formatting()).unwrap_or_default();
|
||||
para.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
if para.props.numbering.is_some()
|
||||
&& let Some(ch) = ppr.child("w:numPr").and_then(|n| n.child("w:numberingChange"))
|
||||
{
|
||||
let original = ch.attr("w:original").unwrap_or("").chars().take(256).collect();
|
||||
para.props.num_change = Some(Box::new(NumChange { rev: self.revision(RevisionKind::Format, ch), original }));
|
||||
}
|
||||
if let Some(s) = ppr.child("w:sectPr") {
|
||||
para.section = Some(Box::new(self.read_section(s, rels)));
|
||||
|
|
@ -265,8 +278,21 @@ impl Reader<'_> {
|
|||
para
|
||||
}
|
||||
|
||||
/// A tracked run formatting change in `rpr` (`w:rPrChange`, §17.13.5.31): the formatting
|
||||
/// before it. A change nested inside it is ignored.
|
||||
fn rpr_change(&mut self, rpr: &El) -> Option<Box<PropChange<CharProps>>> {
|
||||
let ch = rpr.child("w:rPrChange")?;
|
||||
let old = ch.child("w:rPr").map(|o| self.pc.rpr(o).formatting()).unwrap_or_default();
|
||||
PropChange::boxed(self.revision(RevisionKind::Format, ch), old)
|
||||
}
|
||||
|
||||
pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps {
|
||||
let (mut sp, refs) = sectpr(s);
|
||||
// §17.13.5.32: the section's properties before a tracked change (no header references).
|
||||
if let Some(ch) = s.child("w:sectPrChange") {
|
||||
let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default();
|
||||
sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
for r in refs {
|
||||
let Some(rel) = rels.by_id(&r.rid) else { continue };
|
||||
if rel.external {
|
||||
|
|
@ -368,11 +394,12 @@ impl Reader<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_props(&self, rpr: &El, ctx: &RunCtx) -> CharProps {
|
||||
fn run_props(&mut self, rpr: &El, ctx: &RunCtx) -> CharProps {
|
||||
let mut p = self.pc.rpr(rpr);
|
||||
p.link = ctx.link.clone();
|
||||
p.ins = ctx.ins;
|
||||
p.del = ctx.del;
|
||||
p.fmt_change = self.rpr_change(rpr);
|
||||
p
|
||||
}
|
||||
fn run_props_none(&self, ctx: &RunCtx) -> CharProps {
|
||||
|
|
@ -935,6 +962,10 @@ impl Reader<'_> {
|
|||
let mut table = Table::default();
|
||||
if let Some(p) = t.child("w:tblPr") {
|
||||
table.props = self.pc.tblpr(p);
|
||||
if let Some(ch) = p.child("w:tblPrChange") {
|
||||
let old = ch.child("w:tblPr").map(|o| self.pc.tblpr(o)).unwrap_or_default();
|
||||
table.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
}
|
||||
if let Some(g) = t.child("w:tblGrid") {
|
||||
table.grid = g
|
||||
|
|
@ -947,11 +978,19 @@ impl Reader<'_> {
|
|||
collect(t, "w:tr", &mut rows, 0);
|
||||
for tr in rows.into_iter().take(MAX_ROWS) {
|
||||
let mut row = Row { props: tr.child("w:trPr").map(trpr).unwrap_or_default(), cells: Vec::new() };
|
||||
if let Some(ch) = tr.child("w:trPr").and_then(|p| p.child("w:trPrChange")) {
|
||||
let old = ch.child("w:trPr").map(trpr).unwrap_or_default();
|
||||
row.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
let mut tcs = Vec::new();
|
||||
collect(tr, "w:tc", &mut tcs, 0);
|
||||
for tc in tcs.into_iter().take(MAX_COLS) {
|
||||
let (props, hcont) =
|
||||
let (mut props, hcont) =
|
||||
tc.child("w:tcPr").map(tcpr).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();
|
||||
props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
if hcont && let Some(prev) = row.cells.last_mut() {
|
||||
prev.props.span = (prev.props.span + props.span.max(1)).min(63);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
|
|||
w.close("w:rPrDefault");
|
||||
w.open("w:pPrDefault", &[]);
|
||||
w.open("w:pPr", &[]);
|
||||
props::ppr_inner(&mut w, &s.default_para, false);
|
||||
props::ppr_inner(&mut w, &s.default_para, false, None);
|
||||
w.close("w:pPr");
|
||||
w.close("w:pPrDefault");
|
||||
w.close("w:docDefaults");
|
||||
|
|
@ -631,7 +631,7 @@ fn style_xml(w: &mut W, st: &Style) {
|
|||
}
|
||||
if !st.para.is_empty() && st.kind != StyleKind::Character {
|
||||
w.open("w:pPr", &[]);
|
||||
props::ppr_inner(w, &st.para, false);
|
||||
props::ppr_inner(w, &st.para, false, None);
|
||||
w.close("w:pPr");
|
||||
}
|
||||
if props::has_rpr(&st.chr) && st.kind != StyleKind::Numbering {
|
||||
|
|
|
|||
|
|
@ -108,13 +108,27 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Does `c` produce any `w:rPr` content?
|
||||
/// Does `c` produce any `w:rPr` content (besides a tracked change)?
|
||||
pub fn has_rpr(c: &CharProps) -> bool {
|
||||
let mut x = c.clone();
|
||||
x.link = None;
|
||||
x.ins = None;
|
||||
x.del = None;
|
||||
!x.is_empty()
|
||||
!c.formatting().is_empty()
|
||||
}
|
||||
|
||||
/// Attributes of a tracked change element: `w:id`, `w:author`, `w:date`.
|
||||
pub type ChangeAttrs = Vec<(&'static str, String)>;
|
||||
|
||||
/// Open a tracked change element (`w:rPrChange`, `w:pPrChange`, `w:tblPrChange`…).
|
||||
pub fn open_change(w: &mut W, tag: &str, a: &ChangeAttrs) {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.open(tag, &refs);
|
||||
}
|
||||
|
||||
/// A tracked change element holding the properties before the change: `<tag attrs><inner>…</inner></tag>`.
|
||||
fn change_el(w: &mut W, tag: &str, inner: &str, a: &ChangeAttrs, body: impl FnOnce(&mut W)) {
|
||||
open_change(w, tag, a);
|
||||
w.open(inner, &[]);
|
||||
body(w);
|
||||
w.close(inner);
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
pub fn rpr(w: &mut W, c: &CharProps) {
|
||||
|
|
@ -173,7 +187,9 @@ pub fn borders(w: &mut W, tag: &str, b: &Borders, inner: Option<&str>, extra: &[
|
|||
}
|
||||
|
||||
/// `w:pPr` content except the paragraph mark `w:rPr` and `w:sectPr`, which the caller appends.
|
||||
pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool) {
|
||||
/// `w:pPr` content (without the wrapper); `num_change` are the attributes of a tracked list
|
||||
/// numbering change (`w:numberingChange`, written in `w:numPr`).
|
||||
pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&ChangeAttrs>) {
|
||||
if let Some(s) = &p.style {
|
||||
w.val("w:pStyle", s);
|
||||
}
|
||||
|
|
@ -191,6 +207,10 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool) {
|
|||
w.open("w:numPr", &[]);
|
||||
w.val("w:ilvl", &n(nr.level.min(8) as i64));
|
||||
w.val("w:numId", &n(nr.num as i64));
|
||||
if let Some(a) = num_change {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:numberingChange", &refs);
|
||||
}
|
||||
w.close("w:numPr");
|
||||
}
|
||||
toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers);
|
||||
|
|
@ -323,8 +343,17 @@ pub fn look_attrs(l: &TableLook) -> Vec<(&'static str, String)> {
|
|||
]
|
||||
}
|
||||
|
||||
pub fn tblpr(w: &mut W, t: &TableProps) {
|
||||
/// `w:tblPr`, with the properties before a tracked change last when `change` gives its attributes.
|
||||
pub fn tblpr(w: &mut W, t: &TableProps, change: Option<&ChangeAttrs>) {
|
||||
w.open("w:tblPr", &[]);
|
||||
tblpr_inner(w, t);
|
||||
if let (Some(a), Some(ch)) = (change, &t.fmt_change) {
|
||||
change_el(w, "w:tblPrChange", "w:tblPr", a, |w| tblpr_inner(w, &ch.old));
|
||||
}
|
||||
w.close("w:tblPr");
|
||||
}
|
||||
|
||||
fn tblpr_inner(w: &mut W, t: &TableProps) {
|
||||
if let Some(s) = &t.style {
|
||||
w.val("w:tblStyle", s);
|
||||
}
|
||||
|
|
@ -362,14 +391,23 @@ pub fn tblpr(w: &mut W, t: &TableProps) {
|
|||
if let Some(c) = &t.caption {
|
||||
w.val("w:tblCaption", c);
|
||||
}
|
||||
w.close("w:tblPr");
|
||||
}
|
||||
|
||||
pub fn trpr(w: &mut W, r: &RowProps) {
|
||||
if r.height.is_none() && !r.header && !r.cant_split {
|
||||
/// `w:trPr` (when the row has any), with a tracked change last as for [`tblpr`].
|
||||
pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) {
|
||||
let change = change.zip(r.fmt_change.as_ref());
|
||||
if r.height.is_none() && !r.header && !r.cant_split && change.is_none() {
|
||||
return;
|
||||
}
|
||||
w.open("w:trPr", &[]);
|
||||
trpr_inner(w, r);
|
||||
if let Some((a, ch)) = change {
|
||||
change_el(w, "w:trPrChange", "w:trPr", a, |w| trpr_inner(w, &ch.old));
|
||||
}
|
||||
w.close("w:trPr");
|
||||
}
|
||||
|
||||
fn trpr_inner(w: &mut W, r: &RowProps) {
|
||||
if r.cant_split {
|
||||
w.empty("w:cantSplit", &[]);
|
||||
}
|
||||
|
|
@ -384,11 +422,19 @@ pub fn trpr(w: &mut W, r: &RowProps) {
|
|||
if r.header {
|
||||
w.empty("w:tblHeader", &[]);
|
||||
}
|
||||
w.close("w:trPr");
|
||||
}
|
||||
|
||||
pub fn tcpr(w: &mut W, c: &CellProps) {
|
||||
/// `w:tcPr`, with a tracked change last as for [`tblpr`].
|
||||
pub fn tcpr(w: &mut W, c: &CellProps, change: Option<&ChangeAttrs>) {
|
||||
w.open("w:tcPr", &[]);
|
||||
tcpr_inner(w, c);
|
||||
if let (Some(a), Some(ch)) = (change, &c.fmt_change) {
|
||||
change_el(w, "w:tcPrChange", "w:tcPr", a, |w| tcpr_inner(w, &ch.old));
|
||||
}
|
||||
w.close("w:tcPr");
|
||||
}
|
||||
|
||||
fn tcpr_inner(w: &mut W, c: &CellProps) {
|
||||
match (c.width, c.width_pct) {
|
||||
(Some(v), _) => w.empty("w:tcW", &[("w:w", &twips(v.max(0.0))), ("w:type", "dxa")]),
|
||||
(None, Some(p)) => w.empty("w:tcW", &[("w:w", &n((p.clamp(0.0, 100.0) * 50.0).round() as i64)), ("w:type", "pct")]),
|
||||
|
|
@ -422,7 +468,6 @@ pub fn tcpr(w: &mut W, c: &CellProps) {
|
|||
VAlign::Center => w.val("w:vAlign", "center"),
|
||||
VAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
w.close("w:tcPr");
|
||||
}
|
||||
|
||||
/// `w:tblpPr` and `w:tblOverlap` for a floating table.
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
use wordcraft_doc::effects::ShapeEffects;
|
||||
use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{CharProps, Rgb};
|
||||
use wordcraft_doc::props::{CharProps, PropChange, Rgb};
|
||||
use wordcraft_doc::section::{LineNumberRestart, SectionProps, SectionStart};
|
||||
use wordcraft_doc::table::Table;
|
||||
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind};
|
||||
|
||||
use super::props::{borders, ppr_inner, rpr, rpr_inner, tblpr, tcpr, trpr};
|
||||
use super::props::{ChangeAttrs, borders, open_change, ppr_inner, rpr, rpr_inner, tblpr, tcpr, trpr};
|
||||
use super::{PartRels, Writer};
|
||||
use crate::package::rt;
|
||||
use crate::units::{emu, n, twips};
|
||||
|
|
@ -100,10 +100,15 @@ impl Writer<'_> {
|
|||
.into_iter()
|
||||
.filter_map(|(idx, tag)| idx.filter(|i| self.rev_kind(Some(*i)).is_some_and(|k| k != RevisionKind::Format)).map(|i| (i, tag)))
|
||||
.collect();
|
||||
let has_mark = super::props::has_rpr(&p.mark) || !mark_revs.is_empty();
|
||||
let has_mark = super::props::has_rpr(&p.mark) || !mark_revs.is_empty() || p.mark.fmt_change.is_some();
|
||||
if !pp.is_empty() || has_mark || section.is_some() {
|
||||
w.open("w:pPr", &[]);
|
||||
ppr_inner(w, &pp, framed);
|
||||
let num_change = pp.num_change.as_ref().map(|c| {
|
||||
let mut a = self.change_attrs(c.rev);
|
||||
a.push(("w:original", c.original.clone()));
|
||||
a
|
||||
});
|
||||
ppr_inner(w, &pp, framed, num_change.as_ref());
|
||||
if has_mark {
|
||||
w.open("w:rPr", &[]);
|
||||
for (idx, tag) in mark_revs {
|
||||
|
|
@ -115,11 +120,23 @@ impl Writer<'_> {
|
|||
}
|
||||
}
|
||||
rpr_inner(w, &p.mark);
|
||||
if let Some(ch) = p.mark.fmt_change.as_deref() {
|
||||
self.rpr_change(w, ch);
|
||||
}
|
||||
w.close("w:rPr");
|
||||
}
|
||||
if let Some(s) = section {
|
||||
self.sectpr(w, s, rels);
|
||||
}
|
||||
// The paragraph properties before a tracked change come last (§17.13.5.29).
|
||||
if let Some(ch) = &pp.fmt_change {
|
||||
let a = self.change_attrs(ch.rev);
|
||||
open_change(w, "w:pPrChange", &a);
|
||||
w.open("w:pPr", &[]);
|
||||
ppr_inner(w, &ch.old, false, None);
|
||||
w.close("w:pPr");
|
||||
w.close("w:pPrChange");
|
||||
}
|
||||
w.close("w:pPr");
|
||||
}
|
||||
// A note's first paragraph starts with its reference mark. When the paragraph holds the
|
||||
|
|
@ -226,7 +243,7 @@ impl Writer<'_> {
|
|||
let del = self.is_del(props);
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
self.rpr(w, props);
|
||||
for (pc, _) in pieces.get(i..j).unwrap_or(&[]) {
|
||||
match pc {
|
||||
Piece::Text(t) if !t.is_empty() => w.leaf(if del { "w:delText" } else { "w:t" }, &[("xml:space", "preserve")], t),
|
||||
|
|
@ -265,6 +282,34 @@ impl Writer<'_> {
|
|||
(self.next_rev_id.to_string(), author, date)
|
||||
}
|
||||
|
||||
/// `w:id`, `w:author` and `w:date` of a tracked change by revision `idx`.
|
||||
fn change_attrs(&mut self, idx: u32) -> ChangeAttrs {
|
||||
let (id, author, date) = self.rev_attrs(idx);
|
||||
let mut a = vec![("w:id", id), ("w:author", author)];
|
||||
if !date.is_empty() {
|
||||
a.push(("w:date", date));
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// A run's `w:rPr`, with its tracked formatting change last (§17.13.5.31).
|
||||
fn rpr(&mut self, w: &mut W, c: &CharProps) {
|
||||
let Some(ch) = c.fmt_change.as_deref() else { return rpr(w, c) };
|
||||
w.open("w:rPr", &[]);
|
||||
rpr_inner(w, c);
|
||||
self.rpr_change(w, ch);
|
||||
w.close("w:rPr");
|
||||
}
|
||||
|
||||
fn rpr_change(&mut self, w: &mut W, ch: &PropChange<CharProps>) {
|
||||
let a = self.change_attrs(ch.rev);
|
||||
open_change(w, "w:rPrChange", &a);
|
||||
w.open("w:rPr", &[]);
|
||||
rpr_inner(w, &ch.old);
|
||||
w.close("w:rPr");
|
||||
w.close("w:rPrChange");
|
||||
}
|
||||
|
||||
fn rev_open(&mut self, w: &mut W, props: &CharProps) {
|
||||
for (idx, tag) in [(props.ins, "w:ins"), (props.del, "w:del")] {
|
||||
let Some(idx) = idx else { continue };
|
||||
|
|
@ -345,7 +390,7 @@ impl Writer<'_> {
|
|||
}
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, &p);
|
||||
self.rpr(w, &p);
|
||||
w.empty(mark, &[]);
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
|
|
@ -359,7 +404,7 @@ impl Writer<'_> {
|
|||
}
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, &p);
|
||||
self.rpr(w, &p);
|
||||
let tag = if foot { "w:footnoteReference" } else { "w:endnoteReference" };
|
||||
if custom.is_empty() {
|
||||
w.empty(tag, &[("w:id", &nid)]);
|
||||
|
|
@ -377,7 +422,7 @@ impl Writer<'_> {
|
|||
let Some(inner) = self.embedded_xml(src, rels, None) else { return };
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = match graphic.kind {
|
||||
|
|
@ -399,7 +444,7 @@ impl Writer<'_> {
|
|||
{
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
self.rpr(w, props);
|
||||
w.raw(&obj);
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
|
|
@ -408,7 +453,7 @@ impl Writer<'_> {
|
|||
let Some(file) = self.media_files.get(media).cloned() else { return };
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Picture {docpr}");
|
||||
|
|
@ -427,7 +472,7 @@ impl Writer<'_> {
|
|||
InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, .. } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
// Ink is told apart by its name (and its pen), which reading looks for.
|
||||
|
|
@ -453,7 +498,7 @@ impl Writer<'_> {
|
|||
InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Group {docpr}");
|
||||
|
|
@ -502,7 +547,7 @@ impl Writer<'_> {
|
|||
} else if !text.is_empty() {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
self.rpr(w, props);
|
||||
w.leaf("w:t", &[("xml:space", "preserve")], text);
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
|
|
@ -782,7 +827,8 @@ impl Writer<'_> {
|
|||
return;
|
||||
}
|
||||
w.open("w:tbl", &[]);
|
||||
tblpr(w, &t.props);
|
||||
let chg = t.props.fmt_change.as_ref().map(|c| self.change_attrs(c.rev));
|
||||
tblpr(w, &t.props, chg.as_ref());
|
||||
w.open("w:tblGrid", &[]);
|
||||
let cols = t.cols().clamp(1, 63);
|
||||
for g in 0..cols {
|
||||
|
|
@ -792,10 +838,12 @@ impl Writer<'_> {
|
|||
w.close("w:tblGrid");
|
||||
for r in rows {
|
||||
w.open("w:tr", &[]);
|
||||
trpr(w, &r.props);
|
||||
let chg = r.props.fmt_change.as_ref().map(|c| self.change_attrs(c.rev));
|
||||
trpr(w, &r.props, chg.as_ref());
|
||||
for c in &r.cells {
|
||||
w.open("w:tc", &[]);
|
||||
tcpr(w, &c.props);
|
||||
let chg = c.props.fmt_change.as_ref().map(|c| self.change_attrs(c.rev));
|
||||
tcpr(w, &c.props, chg.as_ref());
|
||||
self.blocks(w, &c.blocks, rels, false, depth + 1);
|
||||
w.close("w:tc");
|
||||
}
|
||||
|
|
@ -814,6 +862,23 @@ impl Writer<'_> {
|
|||
}
|
||||
}
|
||||
}
|
||||
sectpr_body(w, s);
|
||||
// The properties before a tracked change come last (§17.13.5.32).
|
||||
if let Some(ch) = &s.fmt_change {
|
||||
let a = self.change_attrs(ch.rev);
|
||||
open_change(w, "w:sectPrChange", &a);
|
||||
w.open("w:sectPr", &[]);
|
||||
sectpr_body(w, &ch.old);
|
||||
w.close("w:sectPr");
|
||||
w.close("w:sectPrChange");
|
||||
}
|
||||
w.close("w:sectPr");
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:sectPr` content after the header and footer references.
|
||||
fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
{
|
||||
let start = match s.start {
|
||||
SectionStart::NextPage => "nextPage",
|
||||
SectionStart::Continuous => "continuous",
|
||||
|
|
@ -898,7 +963,6 @@ impl Writer<'_> {
|
|||
if s.rtl {
|
||||
w.empty("w:bidi", &[]);
|
||||
}
|
||||
w.close("w:sectPr");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1016,3 +1016,62 @@ fn rotated_ole_objects_round_trip() {
|
|||
assert_eq!(spin(o), wordcraft_geom::Spin::new(90.0, true, false));
|
||||
assert_eq!(o.frame().unwrap().2.effect, [0.0; 4], "the rotated overhang is not kept as effects room");
|
||||
}
|
||||
|
||||
/// Issue #41: tracked formatting changes (ECMA-376 §17.13.5) survive open and save — run,
|
||||
/// paragraph mark, paragraph, numbering, table, row, cell and section — each written last in
|
||||
/// its parent, holding the properties before the change.
|
||||
#[test]
|
||||
fn formatting_revisions_round_trip() {
|
||||
let body = r#"
|
||||
<w:p><w:r><w:t xml:space="preserve">Plain, </w:t></w:r><w:r><w:rPr><w:b/><w:rPrChange w:id="5" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:rPr/></w:rPrChange></w:rPr><w:t>bold by Alice</w:t></w:r><w:r><w:rPr><w:sz w:val="28"/><w:rPrChange w:id="99999999999" w:author="Bob"><w:rPr><w:i/><w:sz w:val="24"/><w:rPrChange w:id="1" w:author="Nested"><w:rPr/></w:rPrChange></w:rPr></w:rPrChange></w:rPr><w:t xml:space="preserve"> end.</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/><w:numberingChange w:id="6" w:author="Alice" w:date="2026-10-01T00:00:00Z" w:original="1."/></w:numPr><w:jc w:val="center"/><w:rPr><w:i/><w:rPrChange w:id="7" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:rPr/></w:rPrChange></w:rPr><w:pPrChange w:id="8" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:pPr><w:ind w:left="720"/></w:pPr></w:pPrChange></w:pPr><w:r><w:t>Centred</w:t></w:r></w:p>
|
||||
<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:jc w:val="center"/><w:tblPrChange w:id="9" w:author="Bob"><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr></w:tblPrChange></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
|
||||
<w:tr><w:trPr><w:cantSplit/><w:trPrChange w:id="10" w:author="Bob"><w:trPr/></w:trPrChange></w:trPr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/><w:tcPrChange w:id="11" w:author="Bob"><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr></w:tcPrChange></w:tcPr><w:p><w:r><w:t>cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
|
||||
<w:p/>
|
||||
<w:sectPr><w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/><w:sectPrChange w:id="12" w:author="Carol" w:date="2026-10-02T00:00:00Z"><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:sectPrChange></w:sectPr>"#;
|
||||
let d = read_body(body);
|
||||
let rev = |i: u32| d.revisions.get(i as usize).map(|r| (r.kind, r.author.as_str(), r.date.as_str()));
|
||||
let p = paras(&d);
|
||||
let runs: Vec<_> = p[0].run_ranges().map(|(r, c)| (r, c.clone())).collect();
|
||||
assert_eq!(runs.len(), 3);
|
||||
let alice = runs[1].1.fmt_change.as_deref().expect("Alice's rPrChange");
|
||||
assert_eq!(rev(alice.rev), Some((wordcraft_doc::RevisionKind::Format, "Alice", "2026-10-01T00:00:00Z")));
|
||||
assert_eq!((runs[1].1.bold, alice.old.bold), (Some(true), None));
|
||||
let bob = runs[2].1.fmt_change.as_deref().expect("Bob's rPrChange");
|
||||
assert_eq!((bob.old.italic, bob.old.size, bob.old.fmt_change.is_none()), (Some(true), Some(12.0), true), "a nested change is dropped");
|
||||
assert!(runs[0].1.fmt_change.is_none());
|
||||
let pc = p[1].props.fmt_change.as_deref().expect("pPrChange");
|
||||
assert_eq!((p[1].props.align, pc.old.align, pc.old.indent_left), (Some(Align::Center), None, Some(36.0)));
|
||||
assert_eq!(p[1].props.num_change.as_deref().map(|n| n.original.as_str()), Some("1."));
|
||||
assert!(p[1].mark.fmt_change.is_some());
|
||||
let Some(Block::Table(t)) = d.body.get(2).map(|b| &**b) else { panic!("table") };
|
||||
assert_eq!(t.props.fmt_change.as_deref().map(|c| c.old.align), Some(None));
|
||||
assert_eq!(t.rows[0].props.fmt_change.as_deref().map(|c| c.old.cant_split), Some(false));
|
||||
assert_eq!(t.rows[0].cells[0].props.fmt_change.as_deref().map(|c| c.old.shading), Some(None));
|
||||
let sc = d.last_section.fmt_change.as_deref().expect("sectPrChange");
|
||||
assert_eq!((d.last_section.landscape, sc.old.landscape), (true, false));
|
||||
assert_eq!(rev(sc.rev).map(|r| r.1), Some("Carol"));
|
||||
|
||||
// Written back: each change is the last child of its parent, and reads back the same.
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
for (tag, parent) in [
|
||||
("w:rPrChange", "</w:rPr></w:rPrChange></w:rPr>"),
|
||||
("w:pPrChange", "</w:pPr></w:pPrChange></w:pPr>"),
|
||||
("w:tblPrChange", "</w:tblPr></w:tblPrChange></w:tblPr>"),
|
||||
("w:trPrChange", "</w:trPrChange></w:trPr>"),
|
||||
("w:tcPrChange", "</w:tcPr></w:tcPrChange></w:tcPr>"),
|
||||
("w:sectPrChange", "</w:sectPr></w:sectPrChange></w:sectPr>"),
|
||||
] {
|
||||
assert!(xml.contains(&format!("<{tag} w:id=")), "{tag}: {xml}");
|
||||
assert!(xml.contains(parent), "{tag} last: {xml}");
|
||||
}
|
||||
assert!(xml.contains(r#"<w:numberingChange w:id="#) && xml.contains(r#"w:original="1."/></w:numPr>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<w:b/><w:bCs/><w:rPrChange w:id="#), "{xml}");
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(again.body, d.body);
|
||||
assert_eq!(again.last_section, d.last_section);
|
||||
assert_eq!(again.revisions, d.revisions);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ fn every_char_prop_round_trips() {
|
|||
link: None,
|
||||
ins: None,
|
||||
del: None,
|
||||
fmt_change: None,
|
||||
};
|
||||
let auto =
|
||||
CharProps { color: Some(TextColor::Auto), highlight: Some(Highlight::None), vert_align: Some(VertAlign::Subscript), ..Default::default() };
|
||||
|
|
@ -142,6 +143,8 @@ fn every_para_prop_round_trips() {
|
|||
top_line_punct: Some(true),
|
||||
auto_space_de: Some(false),
|
||||
auto_space_dn: Some(true),
|
||||
num_change: None,
|
||||
fmt_change: None,
|
||||
};
|
||||
let variants = [
|
||||
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
|
||||
|
|
@ -231,7 +234,7 @@ fn tables_with_merges_round_trip() {
|
|||
t.props.look = TableLook { header_row: true, total_row: true, banded_rows: false, first_column: false, last_column: true, banded_columns: true };
|
||||
t.props.shading = Some(Rgb(1, 1, 1));
|
||||
t.props.caption = Some("Sales".into());
|
||||
t.rows[0].props = RowProps { height: Some(20.0), height_rule: HeightRule::Exact, header: true, cant_split: true };
|
||||
t.rows[0].props = RowProps { height: Some(20.0), height_rule: HeightRule::Exact, header: true, cant_split: true, fmt_change: None };
|
||||
t.rows[1].props = RowProps { height: Some(15.0), height_rule: HeightRule::AtLeast, ..Default::default() };
|
||||
t.merge(0, 0, 0, 1); // horizontal
|
||||
t.merge(1, 2, 2, 2); // vertical
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
//! Tracked formatting changes (#41): recording them while Track Changes is on, describing them
|
||||
//! ("Formatted: Bold, Size: 14 pt") and accepting or rejecting them.
|
||||
//!
|
||||
//! Recording isn't done by each formatting command: after any command that edits the document,
|
||||
//! [`record`] compares the paragraphs it changed with the document before it. Where a paragraph
|
||||
//! kept its text but its runs, mark or properties were formatted differently, the change is kept
|
||||
//! as a formatting revision holding the formatting before it (`w:rPrChange` / `w:pPrChange`).
|
||||
//! Like Word, an author's later changes to the same text keep the formatting before their first
|
||||
//! one, and formatting changed back to what it was stops being a change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
|
||||
use wordcraft_doc::section::SectionProps;
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Revision, RevisionKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Commands whose formatting edits aren't the author's own changes: accepting or rejecting
|
||||
/// changes, comparing documents, undo and redo, and opening or replacing the document.
|
||||
fn records(id: &str) -> bool {
|
||||
!(id.starts_with("review.accept")
|
||||
|| id.starts_with("review.reject")
|
||||
|| matches!(id, "review.compare" | "review.combine" | "edit.undo" | "edit.redo")
|
||||
|| id.starts_with("file."))
|
||||
}
|
||||
|
||||
/// Tables nested deeper than this aren't compared (hostile documents).
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
struct Recorder<'a> {
|
||||
author: String,
|
||||
date: String,
|
||||
before: &'a [Revision],
|
||||
revisions: &'a mut Vec<Revision>,
|
||||
rid: Option<u32>,
|
||||
}
|
||||
|
||||
impl Recorder<'_> {
|
||||
/// The author's formatting revision for this command (made on first use).
|
||||
fn rid(&mut self) -> u32 {
|
||||
if let Some(r) = self.rid {
|
||||
return r;
|
||||
}
|
||||
let found = self.revisions.iter().rposition(|r| r.kind == RevisionKind::Format && r.author == self.author && r.date == self.date);
|
||||
let r = match found {
|
||||
Some(i) => i as u32,
|
||||
None => {
|
||||
self.revisions.push(Revision { kind: RevisionKind::Format, author: self.author.clone(), date: self.date.clone() });
|
||||
self.revisions.len().saturating_sub(1) as u32
|
||||
}
|
||||
};
|
||||
self.rid = Some(r);
|
||||
r
|
||||
}
|
||||
|
||||
fn own(&self, rev: u32) -> bool {
|
||||
self.before.get(rev as usize).is_some_and(|r| r.author == self.author)
|
||||
}
|
||||
|
||||
/// The tracked change `after` should carry, given the props `before` the command (`f` strips
|
||||
/// everything that isn't formatting); `None` when it already carries the right one.
|
||||
#[allow(clippy::type_complexity, clippy::option_option)]
|
||||
fn change<T: Clone + PartialEq>(
|
||||
&mut self,
|
||||
before: &T,
|
||||
after: &T,
|
||||
f: fn(&T) -> T,
|
||||
get: fn(&T) -> &Option<Box<PropChange<T>>>,
|
||||
) -> Option<Option<Box<PropChange<T>>>> {
|
||||
let (fb, fa) = (f(before), f(after));
|
||||
let (had, has) = (get(before), get(after));
|
||||
let want = if fb == fa {
|
||||
// Not formatted by this command: keep the change it carries (or carried).
|
||||
if has.is_some() {
|
||||
return None;
|
||||
}
|
||||
had.clone()
|
||||
} else {
|
||||
// The author's own earlier change keeps the formatting before it; another author's
|
||||
// is superseded by this one.
|
||||
let own = had.as_ref().filter(|c| self.own(c.rev));
|
||||
let old = own.map(|c| c.old.clone()).unwrap_or(fb);
|
||||
if old == fa {
|
||||
None
|
||||
} else {
|
||||
let rev = own.map(|c| c.rev).unwrap_or_else(|| self.rid());
|
||||
PropChange::boxed(rev, old)
|
||||
}
|
||||
};
|
||||
(want != *has).then_some(want)
|
||||
}
|
||||
|
||||
/// Record the formatting changes between `b` and `a` (the same text) into `a`.
|
||||
fn para(&mut self, b: &Paragraph, a: &mut Paragraph) {
|
||||
let mut runs = Vec::new();
|
||||
let (rb, ra): (Vec<_>, Vec<_>) = (b.run_ranges().collect(), a.run_ranges().collect());
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while let (Some((x, pb)), Some((y, pa))) = (rb.get(i), ra.get(j)) {
|
||||
let (from, to) = (x.start.max(y.start), x.end.min(y.end));
|
||||
// Formatting text the author is inserting themselves changes the insertion, as in Word.
|
||||
let own_insert = pa.ins.and_then(|i| self.revisions.get(i as usize)).is_some_and(|r| r.author == self.author);
|
||||
if from < to
|
||||
&& !own_insert
|
||||
&& let Some(want) = self.change(*pb, *pa, CharProps::formatting, |c| &c.fmt_change)
|
||||
{
|
||||
runs.push((from, to, want));
|
||||
}
|
||||
if x.end <= y.end {
|
||||
i += 1;
|
||||
}
|
||||
if y.end <= x.end {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
let mark = self.change(&b.mark, &a.mark, CharProps::formatting, |c| &c.fmt_change);
|
||||
let props = self.change(&b.props, &a.props, ParaProps::formatting, |p| &p.fmt_change);
|
||||
if runs.is_empty() && mark.is_none() && props.is_none() {
|
||||
return;
|
||||
}
|
||||
for (from, to, want) in runs {
|
||||
let _ = a.format(from, to, &|c| c.fmt_change = want.clone());
|
||||
}
|
||||
if let Some(m) = mark {
|
||||
a.mark.fmt_change = m;
|
||||
}
|
||||
if let Some(p) = props {
|
||||
a.props.fmt_change = p;
|
||||
}
|
||||
a.normalize();
|
||||
a.touch();
|
||||
}
|
||||
|
||||
fn blocks(&mut self, before: &Blocks, after: &mut Blocks, depth: usize) {
|
||||
if before.len() != after.len() || depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (b, a) in before.iter().zip(after.iter_mut()) {
|
||||
if Arc::ptr_eq(b, a) {
|
||||
continue;
|
||||
}
|
||||
match (&**b, &**a) {
|
||||
(Block::Para(pb), Block::Para(pa)) if pb.text == pa.text => {
|
||||
if let Block::Para(pa) = Arc::make_mut(a) {
|
||||
self.para(pb, pa);
|
||||
}
|
||||
}
|
||||
(Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() => {
|
||||
let Block::Table(ta) = Arc::make_mut(a) else { continue };
|
||||
for (rb, ra) in tb.rows.iter().zip(ta.rows.iter_mut()) {
|
||||
if rb.cells.len() != ra.cells.len() {
|
||||
continue;
|
||||
}
|
||||
for (cb, ca) in rb.cells.iter().zip(ra.cells.iter_mut()) {
|
||||
self.blocks(&cb.blocks, &mut ca.blocks, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the formatting changes the command `id` made to `s.doc` (compared with `before`) as
|
||||
/// tracked formatting changes, when Track Changes and Track Formatting are on.
|
||||
pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting || !records(id) {
|
||||
return;
|
||||
}
|
||||
let author = s.author.clone();
|
||||
let doc = &mut s.doc;
|
||||
let mut r = Recorder { author, date: super::now_iso(), before: &before.revisions, revisions: &mut doc.revisions, rid: None };
|
||||
r.blocks(&before.body, &mut doc.body, 0);
|
||||
for (k, pb) in &before.parts {
|
||||
if let Some(pa) = doc.parts.get_mut(k) {
|
||||
r.blocks(&pb.blocks, &mut pa.blocks, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The properties a change sets, as the Style Inspector lists them: `[{"prop", "value"}]` in
|
||||
/// field order; a switch turned off is `false`, a value removed is `null`.
|
||||
pub fn diff<T: Serialize>(old: &T, new: &T) -> Vec<Value> {
|
||||
let (Ok(Value::Object(o)), Ok(Value::Object(n))) = (serde_json::to_value(old), serde_json::to_value(new)) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in &n {
|
||||
if o.get(k) != Some(v) {
|
||||
out.push(json!({"prop": k, "value": v}));
|
||||
}
|
||||
}
|
||||
for (k, v) in &o {
|
||||
if !n.contains_key(k) {
|
||||
out.push(json!({"prop": k, "value": if v.is_boolean() { json!(false) } else { Value::Null }}));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The properties a run's (or paragraph mark's) change set.
|
||||
pub fn char_diff(c: &CharProps) -> Vec<Value> {
|
||||
c.fmt_change.as_ref().map(|ch| diff(&ch.old.formatting(), &c.formatting())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The properties a paragraph's change set.
|
||||
pub fn para_diff(p: &ParaProps) -> Vec<Value> {
|
||||
p.fmt_change.as_ref().map(|ch| diff(&ch.old.formatting(), &p.formatting())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A section's change: the properties it set.
|
||||
pub fn section_diff(s: &SectionProps) -> Vec<Value> {
|
||||
let strip = |s: &SectionProps| SectionProps { fmt_change: None, headers: Default::default(), footers: Default::default(), ..s.clone() };
|
||||
s.fmt_change.as_ref().map(|ch| diff(&strip(&ch.old), &strip(s))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// "Formatted: Bold, Not italic, Size: 14" — the change in plain English, for agents.
|
||||
pub fn describe(props: &[Value]) -> String {
|
||||
let items: Vec<String> = props
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let label = words(d["prop"].as_str().unwrap_or(""));
|
||||
match &d["value"] {
|
||||
Value::Bool(true) => label,
|
||||
Value::Bool(false) => format!("Not {}", label.to_lowercase()),
|
||||
Value::Null => format!("{label}: (default)"),
|
||||
Value::String(s) => format!("{label}: {s}"),
|
||||
Value::Number(n) => {
|
||||
let x = n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0);
|
||||
let t = format!("{x:.2}");
|
||||
format!("{label}: {}", t.trim_end_matches('0').trim_end_matches('.'))
|
||||
}
|
||||
v => format!("{label}: {v}"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() { "Formatted".into() } else { format!("Formatted: {}", items.join(", ")) }
|
||||
}
|
||||
|
||||
/// `indentLeft` → `Indent left`.
|
||||
fn words(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
for (i, c) in s.chars().take(64).enumerate() {
|
||||
if i == 0 {
|
||||
out.extend(c.to_uppercase());
|
||||
} else if c.is_uppercase() {
|
||||
out.push(' ');
|
||||
out.extend(c.to_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Accept (drop the record) or reject (restore the old formatting) a run's change.
|
||||
pub fn resolve_char(c: &mut CharProps, accept: bool) {
|
||||
let Some(ch) = c.fmt_change.take() else { return };
|
||||
if !accept {
|
||||
*c = CharProps { link: c.link.take(), ins: c.ins, del: c.del, ..ch.old.formatting() };
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept or reject a paragraph's change (its list numbering change goes with it).
|
||||
pub fn resolve_para(p: &mut ParaProps, accept: bool) {
|
||||
p.num_change = None;
|
||||
let Some(ch) = p.fmt_change.take() else { return };
|
||||
if !accept {
|
||||
*p = ch.old.formatting();
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept or reject every table, row, cell and section change in `blocks` (paragraph changes
|
||||
/// are resolved with their text).
|
||||
pub fn resolve_containers(blocks: &mut Blocks, accept: bool, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for b in blocks.iter_mut() {
|
||||
let needs = match &**b {
|
||||
Block::Para(p) => p.section.as_ref().is_some_and(|s| s.fmt_change.is_some()),
|
||||
Block::Table(_) => true,
|
||||
};
|
||||
if !needs {
|
||||
continue;
|
||||
}
|
||||
match Arc::make_mut(b) {
|
||||
Block::Para(p) => {
|
||||
if let Some(s) = p.section.as_deref_mut() {
|
||||
resolve_section(s, accept);
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
Block::Table(t) => {
|
||||
resolve_table(t, accept);
|
||||
for r in &mut t.rows {
|
||||
resolve_row(r, accept);
|
||||
for c in &mut r.cells {
|
||||
resolve_cell(c, accept);
|
||||
resolve_containers(&mut c.blocks, accept, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept or reject a table's own change (not its rows' or cells').
|
||||
pub fn resolve_table(t: &mut Table, accept: bool) {
|
||||
if let Some(ch) = t.props.fmt_change.take().filter(|_| !accept) {
|
||||
t.props = TableProps { fmt_change: None, ..ch.old };
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept or reject a row's change.
|
||||
pub fn resolve_row(r: &mut Row, accept: bool) {
|
||||
if let Some(ch) = r.props.fmt_change.take().filter(|_| !accept) {
|
||||
r.props = RowProps { fmt_change: None, ..ch.old };
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept or reject a cell's change. Merges are the table's structure, not formatting: they stay.
|
||||
pub fn resolve_cell(c: &mut Cell, accept: bool) {
|
||||
if let Some(ch) = c.props.fmt_change.take().filter(|_| !accept) {
|
||||
c.props = CellProps { fmt_change: None, span: c.props.span, vmerge: c.props.vmerge, ..ch.old };
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept or reject a section's change (its headers and footers stay).
|
||||
pub fn resolve_section(s: &mut SectionProps, accept: bool) {
|
||||
let Some(ch) = s.fmt_change.take() else { return };
|
||||
if !accept {
|
||||
let (headers, footers) = (std::mem::take(&mut s.headers), std::mem::take(&mut s.footers));
|
||||
*s = SectionProps { headers, footers, fmt_change: None, ..ch.old };
|
||||
}
|
||||
}
|
||||
|
||||
/// A table's, row's or cell's change: the properties it set.
|
||||
pub fn table_diff(t: &TableProps) -> Vec<Value> {
|
||||
t.fmt_change
|
||||
.as_ref()
|
||||
.map(|ch| diff(&TableProps { fmt_change: None, ..ch.old.clone() }, &TableProps { fmt_change: None, ..t.clone() }))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn row_diff(r: &RowProps) -> Vec<Value> {
|
||||
r.fmt_change
|
||||
.as_ref()
|
||||
.map(|ch| diff(&RowProps { fmt_change: None, ..ch.old.clone() }, &RowProps { fmt_change: None, ..r.clone() }))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn cell_diff(c: &CellProps) -> Vec<Value> {
|
||||
c.fmt_change
|
||||
.as_ref()
|
||||
.map(|ch| diff(&CellProps { fmt_change: None, ..ch.old.clone() }, &CellProps { fmt_change: None, ..c.clone() }))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
|
@ -13,9 +13,9 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
|||
pub const LEVELS: [&str; 4] = ["paragraphStyle", "paragraphFormatting", "characterStyle", "characterFormatting"];
|
||||
|
||||
/// Direct character properties that aren't formatting (links, revisions, proofing, direction).
|
||||
const CHAR_SKIP: [&str; 7] = ["style", "link", "ins", "del", "lang", "noProof", "rtl"];
|
||||
const CHAR_SKIP: [&str; 8] = ["style", "link", "ins", "del", "fmtChange", "lang", "noProof", "rtl"];
|
||||
/// Direct paragraph properties the inspector doesn't list (lists have their own UI).
|
||||
const PARA_SKIP: [&str; 2] = ["style", "numbering"];
|
||||
const PARA_SKIP: [&str; 4] = ["style", "numbering", "numChange", "fmtChange"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub mod draw;
|
|||
pub mod edit;
|
||||
pub mod equation;
|
||||
pub mod file;
|
||||
pub mod fmt_revisions;
|
||||
pub mod format;
|
||||
pub mod insert;
|
||||
pub mod inspector;
|
||||
|
|
@ -181,6 +182,8 @@ pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, Cmd
|
|||
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
let mut props = s.typing_props();
|
||||
delete_selection(s)?;
|
||||
// Typed text is new: never deleted, and not a formatting change of the text around it.
|
||||
props.fmt_change = None;
|
||||
if s.doc.settings.track_changes {
|
||||
props.ins = Some(new_revision(s, RevisionKind::Insert));
|
||||
props.del = None;
|
||||
|
|
|
|||
|
|
@ -655,6 +655,8 @@ fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
|||
st.chr.link = None;
|
||||
st.chr.ins = None;
|
||||
st.chr.del = None;
|
||||
st.chr.fmt_change = None;
|
||||
st.para = st.para.formatting();
|
||||
}
|
||||
s.doc.styles.upsert(st);
|
||||
apply_style(s, &json!({"style": id}))?;
|
||||
|
|
@ -699,7 +701,8 @@ fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
|
|||
chr.link = None;
|
||||
chr.ins = None;
|
||||
chr.del = None;
|
||||
let mut pp = para.props.clone();
|
||||
chr.fmt_change = None;
|
||||
let mut pp = para.props.formatting();
|
||||
pp.style = None;
|
||||
pp.numbering = None;
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
|
|||
for r in &mut p.runs {
|
||||
r.props.ins = Some(rid);
|
||||
r.props.del = None;
|
||||
r.props.fmt_change = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
|||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::{Comment, Paragraph, PartKind, Pos, StoryRef, para_block};
|
||||
|
||||
use super::{now_iso, pos_json, sel_result};
|
||||
use super::{fmt_revisions, now_iso, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
@ -261,7 +261,9 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
|
|||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark.
|
||||
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
|
||||
/// the section break on it), `props` the paragraph's own formatting change.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve_para(
|
||||
s: &mut Session,
|
||||
story: StoryRef,
|
||||
|
|
@ -269,26 +271,42 @@ fn resolve_para(
|
|||
from: usize,
|
||||
to: usize,
|
||||
mark: bool,
|
||||
props: bool,
|
||||
accept: bool,
|
||||
) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
let ranges: Vec<(usize, usize, bool, bool)> = para
|
||||
if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) {
|
||||
fmt_revisions::resolve_para(&mut para.props, accept);
|
||||
para.touch();
|
||||
}
|
||||
let ranges: Vec<(usize, usize, bool, bool, bool)> = para
|
||||
.run_ranges()
|
||||
.filter(|(r, _)| r.end > from && r.start < to)
|
||||
.map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some()))
|
||||
.map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some(), c.fmt_change.is_some()))
|
||||
.collect();
|
||||
for (a, b, ins, del) in ranges.into_iter().rev() {
|
||||
for (a, b, ins, del, fmt) in ranges.into_iter().rev() {
|
||||
if (del && accept) || (ins && !accept) {
|
||||
para.delete(a, b)?;
|
||||
} else if ins || del {
|
||||
} else if ins || del || fmt {
|
||||
para.format(a, b, &|c| {
|
||||
c.ins = None;
|
||||
c.del = None;
|
||||
fmt_revisions::resolve_char(c, accept);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if !mark {
|
||||
return Ok(());
|
||||
}
|
||||
if para.mark.fmt_change.is_some() || para.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
|
||||
fmt_revisions::resolve_char(&mut para.mark, accept);
|
||||
if let Some(sec) = para.section.as_deref_mut() {
|
||||
fmt_revisions::resolve_section(sec, accept);
|
||||
}
|
||||
para.touch();
|
||||
}
|
||||
let (ins, del) = (para.mark.ins, para.mark.del);
|
||||
if !mark || (ins.is_none() && del.is_none()) {
|
||||
if ins.is_none() && del.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
|
||||
|
|
@ -304,9 +322,49 @@ fn resolve_para(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept or reject the changes of the table cell `path` is in: the table's, its row's and the
|
||||
/// cell's own. Returns whether there were any.
|
||||
fn resolve_cell(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path, accept: bool) -> Result<bool, CmdError> {
|
||||
let Some((tp, r, c)) = path.cell() else { return Ok(false) };
|
||||
let has = s.doc.table(story, &tp).is_some_and(|t| {
|
||||
let row = t.rows.get(r);
|
||||
t.props.fmt_change.is_some()
|
||||
|| row.is_some_and(|x| x.props.fmt_change.is_some())
|
||||
|| row.and_then(|x| x.cells.get(c)).is_some_and(|x| x.props.fmt_change.is_some())
|
||||
});
|
||||
if !has {
|
||||
return Ok(false);
|
||||
}
|
||||
let t = s.doc.table_mut(story, &tp)?;
|
||||
fmt_revisions::resolve_table(t, accept);
|
||||
if let Some(row) = t.rows.get_mut(r) {
|
||||
fmt_revisions::resolve_row(row, accept);
|
||||
if let Some(cell) = row.cells.get_mut(c) {
|
||||
fmt_revisions::resolve_cell(cell, accept);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A tracked change, for navigation and the Reviewing Pane: its range, kind (`insert`,
|
||||
/// `delete`, `format`), revision, and for a formatting change the properties it set.
|
||||
struct Change {
|
||||
a: Pos,
|
||||
b: Pos,
|
||||
kind: &'static str,
|
||||
rev: Option<u32>,
|
||||
props: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Change {
|
||||
fn format(a: Pos, b: Pos, rev: u32, props: Vec<Value>) -> Change {
|
||||
Change { a, b, kind: "format", rev: Some(rev), props }
|
||||
}
|
||||
}
|
||||
|
||||
/// The tracked paragraph mark at the end of the paragraph at `path`, as a change range: from
|
||||
/// the paragraph's end to the start of the next paragraph (or the end itself when none follows).
|
||||
fn mark_change(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Option<(Pos, Pos, &'static str, Option<u32>)> {
|
||||
fn mark_change(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Option<Change> {
|
||||
let p = s.doc.para(story, path)?;
|
||||
let kind = if p.mark.ins.is_some() {
|
||||
"insert"
|
||||
|
|
@ -317,7 +375,7 @@ fn mark_change(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Opti
|
|||
};
|
||||
let a = Pos { story, path: path.clone(), off: p.len() };
|
||||
let b = if super::has_next_para(s, story, path) { Pos { story, path: path.with_last(path.last().saturating_add(1)), off: 0 } } else { a.clone() };
|
||||
Some((a, b, kind, p.mark.ins.or(p.mark.del)))
|
||||
Some(Change { a, b, kind, rev: p.mark.ins.or(p.mark.del), props: Vec::new() })
|
||||
}
|
||||
|
||||
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
||||
|
|
@ -325,9 +383,15 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
|||
for st in stories {
|
||||
for path in s.doc.para_paths(st).into_iter().rev() {
|
||||
let len = s.doc.para(st, &path).map(|p| p.len()).unwrap_or(0);
|
||||
resolve_para(s, st, &path, 0, len, true, accept)?;
|
||||
resolve_para(s, st, &path, 0, len, true, true, accept)?;
|
||||
}
|
||||
}
|
||||
// Tables, rows, cells and sections.
|
||||
fmt_revisions::resolve_containers(&mut s.doc.body, accept, 0);
|
||||
for part in s.doc.parts.values_mut() {
|
||||
fmt_revisions::resolve_containers(&mut part.blocks, accept, 0);
|
||||
}
|
||||
fmt_revisions::resolve_section(&mut s.doc.last_section, accept);
|
||||
s.doc.revisions.clear();
|
||||
s.clamp_selection();
|
||||
sel_result(s)
|
||||
|
|
@ -337,19 +401,35 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
|
|||
let (a, b) = s.sel.ordered();
|
||||
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
|
||||
let mut caret_mark = None;
|
||||
// A caret in a paragraph whose own formatting changed (and no tracked text around it).
|
||||
let mut caret_props = None;
|
||||
let ranged = a != b;
|
||||
let (a, b) = if a == b {
|
||||
// The change at the caret: the run around it, else the paragraph mark right after it.
|
||||
let found = s
|
||||
.doc
|
||||
.para_at(&a)
|
||||
.and_then(|p| p.run_ranges().find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some())).map(|(r, _)| r));
|
||||
// The change at the caret: the run around it, else the paragraph mark right after it,
|
||||
// else the paragraph's formatting, else the table cell's.
|
||||
let found = s.doc.para_at(&a).and_then(|p| {
|
||||
p.run_ranges()
|
||||
.find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))
|
||||
.map(|(r, _)| r)
|
||||
});
|
||||
let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some());
|
||||
let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some());
|
||||
match found {
|
||||
Some(r) => (Pos { off: r.start, ..a.clone() }, Pos { off: r.end, ..a }),
|
||||
None if mark_change(s, a.story, &a.path).is_some_and(|m| m.0 == a) => {
|
||||
None if mark_fmt || mark_change(s, a.story, &a.path).is_some_and(|m| m.a == a) => {
|
||||
caret_mark = Some(a.path.clone());
|
||||
(a.clone(), a)
|
||||
}
|
||||
None => return nav_change(s, 1),
|
||||
None if para_fmt => {
|
||||
caret_props = Some(a.path.clone());
|
||||
(a.clone(), a)
|
||||
}
|
||||
None => {
|
||||
if resolve_cell(s, a.story, &a.path, accept)? {
|
||||
return sel_result(s);
|
||||
}
|
||||
return nav_change(s, 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(a, b)
|
||||
|
|
@ -360,39 +440,121 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
|
|||
let to = if path == b.path { b.off } else { len };
|
||||
// A paragraph's mark is in the selection when the selection goes on past it.
|
||||
let mark = path != b.path || caret_mark.as_ref() == Some(&path);
|
||||
resolve_para(s, a.story, &path, from, to, mark, accept)?;
|
||||
let props = (ranged && (path != b.path || to > from)) || caret_props.as_ref() == Some(&path);
|
||||
resolve_para(s, a.story, &path, from, to, mark, props, accept)?;
|
||||
if ranged {
|
||||
resolve_cell(s, a.story, &path, accept)?;
|
||||
}
|
||||
}
|
||||
s.sel = Selection::caret(a);
|
||||
s.clamp_selection();
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn changes(s: &Session) -> Vec<(Pos, Pos, &'static str, Option<u32>)> {
|
||||
let mut out = Vec::new();
|
||||
fn changes(s: &Session) -> Vec<Change> {
|
||||
let mut out: Vec<Change> = Vec::new();
|
||||
let mk = |path: &wordcraft_doc::Path, off| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
// A table's, row's and cell's changes sit at the start of the cell's first paragraph.
|
||||
if path.last() == 0
|
||||
&& let Some((tp, r, c)) = path.cell()
|
||||
&& let Some(t) = s.doc.table(StoryRef::Body, &tp)
|
||||
{
|
||||
let row = t.rows.get(r);
|
||||
let cell = row.and_then(|x| x.cells.get(c));
|
||||
let found = [
|
||||
(r == 0 && c == 0).then(|| t.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::table_diff(&t.props)))).flatten(),
|
||||
(c == 0).then(|| row.and_then(|x| x.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::row_diff(&x.props))))).flatten(),
|
||||
cell.and_then(|x| x.props.fmt_change.as_ref().map(|ch| (ch.rev, fmt_revisions::cell_diff(&x.props)))),
|
||||
];
|
||||
for (rev, props) in found.into_iter().flatten() {
|
||||
out.push(Change::format(mk(&path, 0), mk(&path, 0), rev, props));
|
||||
}
|
||||
}
|
||||
if let Some(ch) = &p.props.fmt_change {
|
||||
out.push(Change::format(mk(&path, 0), mk(&path, p.len()), ch.rev, fmt_revisions::para_diff(&p.props)));
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
let kind = if c.ins.is_some() {
|
||||
"insert"
|
||||
} else if c.del.is_some() {
|
||||
"delete"
|
||||
} else {
|
||||
continue;
|
||||
""
|
||||
};
|
||||
let mk = |off| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
out.push((mk(r.start), mk(r.end), kind, c.ins.or(c.del)));
|
||||
if !kind.is_empty() {
|
||||
out.push(Change { a: mk(&path, r.start), b: mk(&path, r.end), kind, rev: c.ins.or(c.del), props: Vec::new() });
|
||||
}
|
||||
let Some(ch) = &c.fmt_change else { continue };
|
||||
let props = fmt_revisions::char_diff(c);
|
||||
// Neighbouring runs changed the same way by the same revision are one change.
|
||||
if let Some(last) =
|
||||
out.last_mut().filter(|l| l.kind == "format" && l.rev == Some(ch.rev) && l.b == mk(&path, r.start) && l.props == props)
|
||||
{
|
||||
last.b = mk(&path, r.end);
|
||||
} else {
|
||||
out.push(Change::format(mk(&path, r.start), mk(&path, r.end), ch.rev, props));
|
||||
}
|
||||
}
|
||||
if let Some(ch) = &p.mark.fmt_change {
|
||||
let end = mk(&path, p.len());
|
||||
out.push(Change::format(end.clone(), end, ch.rev, fmt_revisions::char_diff(&p.mark)));
|
||||
}
|
||||
out.extend(mark_change(s, StoryRef::Body, &path));
|
||||
if let Some(ch) = p.section.as_ref().and_then(|x| x.fmt_change.as_ref().map(|ch| (ch.rev, x))) {
|
||||
let end = mk(&path, p.len());
|
||||
out.push(Change::format(end.clone(), end, ch.0, fmt_revisions::section_diff(ch.1)));
|
||||
}
|
||||
}
|
||||
if let Some(ch) = &s.doc.last_section.fmt_change {
|
||||
let end = s.doc.end_of(StoryRef::Body);
|
||||
out.push(Change::format(end.clone(), end, ch.rev, fmt_revisions::section_diff(&s.doc.last_section)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The formatting changes in the body, for balloons: (position, author, properties set).
|
||||
pub fn format_changes(s: &Session) -> Vec<(Pos, String, Vec<Value>)> {
|
||||
changes(s)
|
||||
.into_iter()
|
||||
.filter(|c| c.kind == "format")
|
||||
.map(|c| {
|
||||
let author = c.rev.and_then(|r| s.doc.revisions.get(r as usize)).map(|r| r.author.clone()).unwrap_or_default();
|
||||
(c.a, author, c.props)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the document has any tracked formatting change (cheap: stops at the first).
|
||||
pub fn has_format_changes(doc: &wordcraft_doc::Document) -> bool {
|
||||
if doc.last_section.fmt_change.is_some() {
|
||||
return true;
|
||||
}
|
||||
doc.para_paths(StoryRef::Body).iter().any(|path| {
|
||||
doc.para(StoryRef::Body, path).is_some_and(|p| {
|
||||
p.props.fmt_change.is_some()
|
||||
|| p.mark.fmt_change.is_some()
|
||||
|| p.runs.iter().any(|r| r.props.fmt_change.is_some())
|
||||
|| p.section.as_ref().is_some_and(|x| x.fmt_change.is_some())
|
||||
|| path.cell().and_then(|(tp, r, c)| doc.table(StoryRef::Body, &tp).map(|t| (t, r, c))).is_some_and(|(t, r, c)| {
|
||||
t.props.fmt_change.is_some()
|
||||
|| t.rows.get(r).is_some_and(|x| x.props.fmt_change.is_some() || x.cells.get(c).is_some_and(|x| x.props.fmt_change.is_some()))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn nav_change(s: &mut Session, dir: i32) -> CmdResult {
|
||||
let list = changes(s);
|
||||
let caret = s.sel.ordered();
|
||||
let t = if dir > 0 { list.iter().find(|c| c.0 >= caret.1).or(list.first()) } else { list.iter().rev().find(|c| c.1 <= caret.0).or(list.last()) };
|
||||
if let Some((a, b, _, _)) = t {
|
||||
s.sel = Selection { anchor: a.clone(), focus: b.clone() };
|
||||
let t = if dir > 0 {
|
||||
list.iter().find(|c| c.a >= caret.1 && (c.a > caret.0 || c.b > caret.1)).or(list.first())
|
||||
} else {
|
||||
list.iter().rev().find(|c| c.b <= caret.0 && (c.b < caret.1 || c.a < caret.0)).or(list.last())
|
||||
};
|
||||
if let Some(c) = t {
|
||||
s.sel = Selection { anchor: c.a.clone(), focus: c.b.clone() };
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
|
@ -401,13 +563,18 @@ fn list_changes(s: &mut Session, _: &Value) -> CmdResult {
|
|||
let list = changes(s);
|
||||
Ok(Value::Array(
|
||||
list.iter()
|
||||
.map(|(a, b, kind, rid)| {
|
||||
let rev = rid.and_then(|r| s.doc.revisions.get(r as usize));
|
||||
json!({
|
||||
"kind": kind, "start": pos_json(a), "end": pos_json(b),
|
||||
"text": s.doc.copy_range(a, b).plain_text(),
|
||||
.map(|c| {
|
||||
let rev = c.rev.and_then(|r| s.doc.revisions.get(r as usize));
|
||||
let mut v = json!({
|
||||
"kind": c.kind, "start": pos_json(&c.a), "end": pos_json(&c.b),
|
||||
"text": s.doc.copy_range(&c.a, &c.b).plain_text(),
|
||||
"author": rev.map(|r| r.author.clone()), "date": rev.map(|r| r.date.clone()),
|
||||
})
|
||||
});
|
||||
if c.kind == "format" {
|
||||
v["description"] = json!(fmt_revisions::describe(&c.props));
|
||||
v["props"] = json!(c.props);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
|
|
@ -695,4 +862,80 @@ mod tests {
|
|||
assert!(s.run("review.trackingOptions", &json!({"deleteMark": "sparkles"})).is_err());
|
||||
assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["insertColor"], "00AA00");
|
||||
}
|
||||
|
||||
fn body_para(s: &Session) -> &wordcraft_doc::Paragraph {
|
||||
s.doc.para(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap()
|
||||
}
|
||||
|
||||
/// "plain words here" with "words" made bold, then 14 pt, and the paragraph centred, under
|
||||
/// Track Changes by Ana.
|
||||
fn formatted_while_tracking() -> Session {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.author = "Ana".into();
|
||||
s.run("document.setText", &json!({"text": "plain words here"})).unwrap();
|
||||
// Untracked formatting is no revision.
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 12}, "focus": {"block": 0, "off": 16}})).unwrap();
|
||||
s.run("format.italic", &json!({})).unwrap();
|
||||
assert!(body_para(&s).runs.iter().all(|r| r.props.fmt_change.is_none()));
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})).unwrap();
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
s.run("format.size", &json!({"size": 14})).unwrap();
|
||||
s.run("para.alignCenter", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// #41: formatting with Track Changes on records the formatting before the author's first
|
||||
/// change; it's listed in the Reviewing Pane and marked by a bar beside the line.
|
||||
#[test]
|
||||
fn formatting_is_tracked_while_tracking_changes() {
|
||||
let mut s = formatted_while_tracking();
|
||||
let p = body_para(&s);
|
||||
let ch = p.props_of_char(6).fmt_change.as_deref().expect("a formatting change on \"words\"");
|
||||
assert_eq!((ch.old.bold, ch.old.size), (None, None), "the formatting before the first change is kept");
|
||||
let rev = &s.doc.revisions[ch.rev as usize];
|
||||
assert_eq!((rev.kind, rev.author.as_str()), (wordcraft_doc::RevisionKind::Format, "Ana"));
|
||||
assert!(p.props_of_char(0).fmt_change.is_none() && p.props_of_char(13).fmt_change.is_none());
|
||||
assert_eq!(p.props.fmt_change.as_deref().map(|c| c.old.align), Some(None));
|
||||
|
||||
let list = s.run("review.changes", &json!({})).unwrap();
|
||||
let desc: Vec<&str> = list.as_array().unwrap().iter().filter(|c| c["kind"] == "format").filter_map(|c| c["description"].as_str()).collect();
|
||||
assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Size: 14"], "{list:#}");
|
||||
|
||||
let (d, body_x) = draws(&mut s);
|
||||
let bars = lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count();
|
||||
assert_eq!(bars, 1, "one changed line, one bar");
|
||||
|
||||
// Formatting changed back is no change any more.
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})).unwrap();
|
||||
s.run("format.set", &json!({"props": {"bold": false}})).unwrap();
|
||||
s.run("format.size", &json!({"size": 11})).unwrap();
|
||||
assert!(body_para(&s).props_of_char(6).fmt_change.is_some(), "explicit Not Bold and 11 pt differ from inherited");
|
||||
}
|
||||
|
||||
/// #41: rejecting restores the formatting before the change; accepting keeps the new
|
||||
/// formatting and drops the record. Both find formatting changes at the caret too.
|
||||
#[test]
|
||||
fn formatting_changes_are_accepted_and_rejected() {
|
||||
let mut s = formatted_while_tracking();
|
||||
s.run("review.rejectAll", &json!({})).unwrap();
|
||||
let p = body_para(&s);
|
||||
let c = p.props_of_char(6);
|
||||
assert_eq!((c.bold, c.size, c.fmt_change.is_none()), (None, None, true));
|
||||
assert_eq!((p.props.align, p.props.fmt_change.is_none()), (None, true));
|
||||
assert_eq!(p.props_of_char(13).italic, Some(true), "untracked formatting stays");
|
||||
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
// Accept at the caret: just the run's change.
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 8}, "focus": {"block": 0, "off": 8}})).unwrap();
|
||||
s.run("review.accept", &json!({})).unwrap();
|
||||
let p = body_para(&s);
|
||||
assert_eq!((p.props_of_char(6).bold, p.props_of_char(6).fmt_change.is_none()), (Some(true), true));
|
||||
assert!(p.props.fmt_change.is_some(), "the paragraph's change is still there");
|
||||
s.run("review.acceptAll", &json!({})).unwrap();
|
||||
let p = body_para(&s);
|
||||
assert_eq!((p.props.align, p.props.fmt_change.is_none()), (Some(wordcraft_doc::props::Align::Center), true));
|
||||
assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -727,6 +727,10 @@ impl Session {
|
|||
match &result {
|
||||
Ok(_) => {
|
||||
if spec.mutates {
|
||||
// Formatting changed while tracking becomes a formatting revision.
|
||||
if let Some((before, ..)) = &before_doc {
|
||||
crate::cmd::fmt_revisions::record(self, id, before);
|
||||
}
|
||||
self.touch();
|
||||
self.doc.ensure_nonempty();
|
||||
self.doc.prune_text_boxes();
|
||||
|
|
|
|||
|
|
@ -320,7 +320,7 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec<D
|
|||
for it in &page.items {
|
||||
item(doc, it, opts, ba, &mut out);
|
||||
}
|
||||
if opts.markup && opts.revisions.insertions_deletions && opts.revisions.changed_lines != ChangeBar::None {
|
||||
if opts.markup && (opts.revisions.insertions_deletions || opts.revisions.formatting) && opts.revisions.changed_lines != ChangeBar::None {
|
||||
change_bars(doc, page, &opts.revisions, ba, &mut out);
|
||||
}
|
||||
out
|
||||
|
|
@ -329,7 +329,8 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec<D
|
|||
/// The automatic colour of the bars beside changed lines.
|
||||
pub const CHANGE_BAR: Rgb = Rgb(0x50, 0x50, 0x50);
|
||||
|
||||
/// Bars in the margin beside body lines with tracked insertions or deletions.
|
||||
/// Bars in the margin beside body lines with tracked insertions or deletions, or (when shown)
|
||||
/// tracked formatting changes of their text, paragraph or paragraph mark.
|
||||
fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
// Outside: the left margin, or the outer one of a right-hand page with mirrored margins.
|
||||
let right = match m.changed_lines {
|
||||
|
|
@ -345,12 +346,17 @@ fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out:
|
|||
continue;
|
||||
}
|
||||
let Some(first) = pl.lines.get(*l0) else { continue };
|
||||
let mark_rev = doc.para(*story, path).is_some_and(|p| p.mark.ins.is_some() || p.mark.del.is_some());
|
||||
let (text, fmt) = (m.insertions_deletions, m.formatting);
|
||||
let para = doc.para(*story, path);
|
||||
let mark_rev = para.is_some_and(|p| (text && (p.mark.ins.is_some() || p.mark.del.is_some())) || (fmt && p.mark.fmt_change.is_some()));
|
||||
// A paragraph whose own formatting changed is marked on every line.
|
||||
let para_fmt = fmt && para.is_some_and(|p| p.props.fmt_change.is_some());
|
||||
for li in *l0..*l1 {
|
||||
let Some(line) = pl.lines.get(li) else { continue };
|
||||
let changed = (line.c0..line.c1)
|
||||
.filter_map(|k| pl.clusters.get(k).and_then(|c| pl.styles.get(c.style as usize)))
|
||||
.any(|st| st.rc.ins.is_some() || st.rc.del.is_some())
|
||||
let changed = para_fmt
|
||||
|| (line.c0..line.c1)
|
||||
.filter_map(|k| pl.clusters.get(k).and_then(|c| pl.styles.get(c.style as usize)))
|
||||
.any(|st| (text && (st.rc.ins.is_some() || st.rc.del.is_some())) || (fmt && st.rc.fmt.is_some()))
|
||||
|| (mark_rev && line.end == LineEnd::Para);
|
||||
if changed {
|
||||
let top = y + (line.top - first.top);
|
||||
|
|
|
|||
|
|
@ -145,10 +145,12 @@ pub fn markup_width(app: &WordApp) -> f32 {
|
|||
let v = &app.session.view;
|
||||
// Word shows comments either in balloons (contextual) or in the Comments pane (list).
|
||||
let on = v.show_markup && !v.comments_pane && !v.read_mode && !v.multi_page && v.mode == wordcraft_layout::ViewMode::Print;
|
||||
// Track Changes Options: comments hidden, or every revision inline, leave no markup area.
|
||||
// Track Changes Options: comments and formatting hidden, or every revision inline, leave
|
||||
// no markup area.
|
||||
let m = &app.session.prefs.markup;
|
||||
let on = on && m.comments && m.balloons != wordcraft_layout::display::BalloonMode::Inline;
|
||||
if on && !app.session.doc.comments.is_empty() { 216.0 } else { 0.0 }
|
||||
let on = on && m.balloons != wordcraft_layout::display::BalloonMode::Inline;
|
||||
let comments = m.comments && !app.session.doc.comments.is_empty();
|
||||
if on && (comments || (m.formatting && wordcraft_engine::cmd::review::has_format_changes(&app.session.doc))) { 216.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A page dimension (points) safe to lay out: finite, at least 1 pt, at most `cap`.
|
||||
|
|
@ -749,15 +751,25 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
|||
replies.entry(p).or_default().push(*id);
|
||||
}
|
||||
}
|
||||
// (page, anchor x, anchor y, id) for each anchored, unresolved-or-not comment.
|
||||
let mut by_page: HashMap<usize, Vec<(f32, f32, u32, Pos)>> = HashMap::new();
|
||||
for (id, pos) in wordcraft_engine::cmd::review::comment_list(&app.session) {
|
||||
// (page, anchor x, anchor y, item) for each anchored, unresolved-or-not comment, and each
|
||||
// tracked formatting change (Word's default shows those in balloons too).
|
||||
let mut by_page: HashMap<usize, Vec<(f32, f32, Balloon, Pos)>> = HashMap::new();
|
||||
let m = &app.session.prefs.markup;
|
||||
let comments = if m.comments { wordcraft_engine::cmd::review::comment_list(&app.session) } else { Vec::new() };
|
||||
for (id, pos) in comments {
|
||||
let Some(pos) = pos else { continue };
|
||||
if app.session.doc.comments.get(&id).is_some_and(is_reply) {
|
||||
continue;
|
||||
}
|
||||
let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue };
|
||||
by_page.entry(c.page).or_default().push((c.x, c.top + c.height, id, pos));
|
||||
by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Comment(id), pos));
|
||||
}
|
||||
let formats = if m.formatting { wordcraft_engine::cmd::review::format_changes(&app.session) } else { Vec::new() };
|
||||
for (pos, author, props) in formats.into_iter().take(500) {
|
||||
let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue };
|
||||
let what = crate::panes::describe_props(&props).join(", ");
|
||||
let text = if what.is_empty() { tl!("Formatted").to_string() } else { format!("{}: {what}", tl!("Formatted")) };
|
||||
by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(author, text), pos));
|
||||
}
|
||||
let palette = [t.blue, Color32::from_rgb(0xB0, 0x3A, 0x2E), Color32::from_rgb(0x2E, 0x7D, 0x32), Color32::from_rgb(0x8E, 0x44, 0xAD), t.orange];
|
||||
let mut authors: Vec<String> = Vec::new();
|
||||
|
|
@ -784,7 +796,38 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
|||
let x0 = pr.max.x + 10.0;
|
||||
let w = (mw * page_scale - 20.0).max(60.0);
|
||||
let mut next_y = pr.min.y;
|
||||
for (ax, ay, id, pos) in list.iter() {
|
||||
for (ax, ay, item, pos) in list.iter() {
|
||||
let id = match item {
|
||||
Balloon::Comment(id) => id,
|
||||
Balloon::Format(author, text) => {
|
||||
// A read-only card: who formatted, and how.
|
||||
let author = author_name(author);
|
||||
let ai = authors.iter().position(|a| *a == author).unwrap_or_else(|| {
|
||||
authors.push(author.clone());
|
||||
authors.len() - 1
|
||||
});
|
||||
let color = palette.get(ai % palette.len()).copied().unwrap_or(t.blue);
|
||||
let fs = (11.0 * page_scale / PX_PER_PT).clamp(8.0, 16.0);
|
||||
let anchor = pos2(pr.min.x + ax * page_scale, pr.min.y + ay * page_scale);
|
||||
let top = (anchor.y - 12.0).max(next_y);
|
||||
let head = painter.layout(author, semibold(fs), t.text, w - 16.0);
|
||||
let body = painter.layout(text.clone(), regular(fs), t.text_dim, w - 16.0);
|
||||
let card = Rect::from_min_size(pos2(x0, top), vec2(w, head.size().y + body.size().y + 16.0));
|
||||
next_y = card.max.y + 6.0;
|
||||
if !card.intersects(clip) {
|
||||
continue;
|
||||
}
|
||||
let lead = Stroke::new(1.0, color.linear_multiply(0.7));
|
||||
dashed(painter, anchor, pos2(pr.max.x, anchor.y), lead);
|
||||
painter.line_segment([pos2(pr.max.x, anchor.y), pos2(x0, top + 10.0)], lead);
|
||||
card_shapes(painter, &t, card, color, false, None);
|
||||
let y = card.min.y + 6.0;
|
||||
let hh = head.size().y;
|
||||
painter.galley(pos2(card.min.x + 10.0, y), head, t.text);
|
||||
painter.galley(pos2(card.min.x + 10.0, y + hh + 4.0), body, t.text_dim);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(c) = app.session.doc.comments.get(id) else { continue };
|
||||
let resolved = c.resolved;
|
||||
let author = author_name(&c.author);
|
||||
|
|
@ -859,6 +902,13 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
|||
}
|
||||
}
|
||||
|
||||
/// What a balloon in the markup area shows.
|
||||
enum Balloon {
|
||||
Comment(u32),
|
||||
/// A tracked formatting change: its author and "Formatted: …".
|
||||
Format(String, String),
|
||||
}
|
||||
|
||||
fn author_name(a: &str) -> String {
|
||||
if a.is_empty() { tl!("Author").to_string() } else { a.to_string() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1326,3 +1326,4 @@
|
|||
Rotate Left 90° Girar 90° a la izquierda
|
||||
Flip Vertical Voltear verticalmente
|
||||
Flip Horizontal Voltear horizontalmente
|
||||
Formatted Con formato
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1323,3 +1323,4 @@
|
|||
Type the field's name and its switches, for example PAGE \\* roman. Sisesta välja nimi ja lülitid, näiteks PAGE \\* roman.
|
||||
Width and spacing Laius ja vahe
|
||||
from Unicode (hex) Unicode (kuueteistkümnend)
|
||||
Formatted Vormindatud
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -1310,3 +1310,4 @@
|
|||
Rotate Left 90° 左へ 90 度回転
|
||||
Flip Vertical 上下反転
|
||||
Flip Horizontal 左右反転
|
||||
Formatted 書式変更
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -1322,3 +1322,4 @@
|
|||
Rotate Left 90° Girar 90° para a esquerda
|
||||
Flip Vertical Inverter verticalmente
|
||||
Flip Horizontal Inverter horizontalmente
|
||||
Formatted Formatado
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1320,3 +1320,4 @@
|
|||
Rotate Left 90° Rotiraj ulevo za 90°
|
||||
Flip Vertical Okreni vertikalno
|
||||
Flip Horizontal Okreni horizontalno
|
||||
Formatted Formatirano
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1324,3 +1324,4 @@
|
|||
Rotate Left 90° Ротирај улево за 90°
|
||||
Flip Vertical Окрени вертикално
|
||||
Flip Horizontal Окрени хоризонтално
|
||||
Formatted Форматирано
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
|
|
@ -1322,3 +1322,4 @@
|
|||
Rotate Left 90° Повернути ліворуч на 90°
|
||||
Flip Vertical Віддзеркалити по вертикалі
|
||||
Flip Horizontal Віддзеркалити по горизонталі
|
||||
Formatted Відформатовано
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -1321,3 +1321,4 @@
|
|||
Rotate Left 90° 向左旋转 90°
|
||||
Flip Vertical 垂直翻转
|
||||
Flip Horizontal 水平翻转
|
||||
Formatted 已设置格式
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -1311,3 +1311,4 @@
|
|||
Rotate Left 90° 向左旋轉 90°
|
||||
Flip Vertical 垂直翻轉
|
||||
Flip Horizontal 水平翻轉
|
||||
Formatted 已設定格式
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -518,6 +518,11 @@ fn direct_list(v: &Value) -> Vec<String> {
|
|||
v.as_array().map(|a| a.iter().take(64).map(|d| describe(d["prop"].as_str().unwrap_or(""), &d["value"])).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Each `{"prop", "value"}` of a list (the Style Inspector's, or a formatting change's) as text.
|
||||
pub(crate) fn describe_props(list: &[Value]) -> Vec<String> {
|
||||
list.iter().take(64).map(|d| describe(d["prop"].as_str().unwrap_or(""), &d["value"])).collect()
|
||||
}
|
||||
|
||||
/// "Bold", "Not Italic", "Font: Arial", "Size: 14 pt"…
|
||||
fn describe(prop: &str, v: &Value) -> String {
|
||||
let label = match prop {
|
||||
|
|
@ -562,6 +567,9 @@ fn describe(prop: &str, v: &Value) -> String {
|
|||
"suppressLineNumbers" => "Suppress line numbers",
|
||||
"bidi" => "Right-to-left",
|
||||
"dropCap" => "Drop cap",
|
||||
"style" => "Style",
|
||||
"numbering" => "Numbering",
|
||||
"lang" => "Language",
|
||||
other => other,
|
||||
};
|
||||
let label = tl!(label);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** 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 (formatting revisions landed, #41; 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;
|
||||
|
|
@ -50,7 +50,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
|||
| Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | |
|
||||
| Footnotes, endnotes, comments | ✅ | ✅ | `commentsExtended` written; `commentsIds`, modern threaded comments (`w16cex`) partly |
|
||||
| Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 |
|
||||
| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`) | ❌ | ❌ | Lost on open. Issue #41 |
|
||||
| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them |
|
||||
| Move tracking (`w:moveFrom`/`w:moveTo`) | 🟡 | ❌ | Read as plain insert/delete |
|
||||
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result |
|
||||
| Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped |
|
||||
|
|
@ -83,6 +83,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
|
||||
| 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed |
|
||||
| 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded |
|
||||
| 2026-10-10 | trivial | DrawingML rotation and flips read and written (#332) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
|
|
@ -15,7 +15,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
|||
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; columns don't balance; custom footnote continuation separators and notices aren't read; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | **Move tracking and content controls** are lost (`w:moveFrom`/`w:moveTo` read as insert/delete; `w:sdt` unwrapped). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 8–12 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 7 | **Dialog depth**: 22 modal dialogs vs Word's ~100 (Define Multilevel List, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Dialog depth**: 19 modal dialogs vs Word's ~100 (Tabs, Borders and Shading, Define Multilevel List, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
|
||||
|
|
@ -66,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 | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h |
|
||||
| 2026-10-10 | trivial | Draw Table and Eraser landed (#303); removed from the feature-gap list |
|
||||
| 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list |
|
||||
| 2026-10-10 | trivial | Columns, Symbol and Field dialogs landed (#321); removed from the dialog-depth list |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WordCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is
|
||||
[`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are
|
||||
|
|
@ -31,9 +31,9 @@ every day, checked end to end on the main platform (macOS desktop), including sa
|
|||
| Core workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---|
|
||||
| Write and format a document: type, fonts, paragraph formatting, styles, lists, undo, find/replace; save as .docx and reopen | yes | 422 commands, typing parity pinned by `tests_typing.rs`, DOCX round-trip tests (`crates/docx/tests/roundtrip.rs`) | 0 |
|
||||
| Open a .docx someone sent, edit it, send back a .docx Word opens | partial (not blocking) | Word opens our files; typical text/table/picture documents work; never tested on a real-world corpus; charts, SmartArt and OLE dropped silently, formatting revisions lost (gaps #1, #2, #5) | 0 (beta work: 50–80) |
|
||||
| Open a .docx someone sent, edit it, send back a .docx Word opens | partial (not blocking) | Word opens our files; typical text/table/picture documents work; never tested on a real-world corpus; charts, SmartArt and OLE dropped silently, move tracking lost (gaps #1, #2, #5) | 0 (beta work: 50–80) |
|
||||
| Build a structured document: tables, pictures, headers/footers, page numbers, sections, columns | yes | Tables with row splitting and floating tables (#137), floating pictures (#136), first/odd/even headers; columns don't balance (gap #4) | 0 |
|
||||
| Review with others: comments, track changes, accept/reject, compare | yes | Margin balloons, tracked insert/delete incl. paragraph marks (#125, #244), compare; formatting revisions missing (#41) | 0 |
|
||||
| Review with others: comments, track changes, accept/reject, compare | yes | Margin balloons, tracked insert/delete incl. paragraph marks (#125, #244), formatting revisions (#41), compare | 0 |
|
||||
| References for reports and papers: TOC, footnotes, citations and bibliography, captions | yes | Updatable TOC field (#52), notes, APA/MLA/Chicago/IEEE, Zotero (#189); long footnotes don't continue (gap #4) | 0 |
|
||||
| Share or print: export PDF, print | partial (not blocking) | PDF export solid (krilla); desktop printing goes through a PDF, no native print dialog (gap #6) | 0 (beta work: 6–10) |
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay
|
|||
| M4 | Layout and Design tabs | done except column balancing, group | 6–10 h |
|
||||
| M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h |
|
||||
| M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation, 8 more bibliography styles missing | 15–25 h |
|
||||
| M7 | Review | done (balloons, paragraph-mark revisions); formatting revisions, move tracking, multilingual proofing missing | 35–60 h |
|
||||
| M7 | Review | done (balloons, paragraph-mark revisions, formatting revisions #41); move tracking, multilingual proofing missing | 35–60 h |
|
||||
| M8 | View | mostly done; window commands missing | 3–5 h |
|
||||
| M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h |
|
||||
| M10 | File/Backstage | mostly done; native printing, Options depth missing | 12–20 h |
|
||||
|
|
@ -71,6 +71,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | M7: formatting revisions landed (#41) |
|
||||
| 2026-10-10 | trivial | M5: Draw Table and Eraser landed (#303) |
|
||||
| 2026-10-10 | trivial | Merged main: PRs that landed removed from the Current focus list |
|
||||
| 2026-10-10 | minor | Current focus heading back to ~150–230 h to beta: full number restored to the additive weighted sum (~60%), method aligned with the standard, no new evidence |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WordCraft parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are
|
||||
|
|
@ -65,7 +65,7 @@ full parity for the area.
|
|||
| Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. |
|
||||
| Footnotes and endnotes | 3 | 60% | 60% | 5–8 | Placed and editable, numbers from Word files (#103). Long notes don't continue onto the next page; no continuation separator; no note options per section. |
|
||||
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. 4 of Word's 12 bibliography styles (APA, MLA, Chicago, IEEE); source manager depth; bibliography sources not yet in DOCX (#169 open). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Missing: formatting revisions (`w:rPrChange`/`w:pPrChange`, issue #41), move tracking, track-changes balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary, a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules, preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |
|
||||
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, Icons, online pictures, screenshot. |
|
||||
|
|
@ -106,7 +106,7 @@ h), pictures and tables (12–18 h), spelling depth (8–12 h), polish (10–20
|
|||
| Interaction fidelity | ×0.93 | — | Typing pinned to Word (`tests_typing.rs`), ribbon, keytips, mini-toolbar; but 16 modal dialogs against Word's ~100, 101 of ~250 shortcuts, no table column drag on the canvas (#217, #49), picture/shape handling complaints (#82, #142), lines wrap differently without an Aptos-metric font |
|
||||
| Discoverability and UI clarity | — | ×0.95 | Word-style ribbon a Word user already knows, command search, keytips; some newer pane strings and tooltips terse or untranslated |
|
||||
| Stability on real machines | ×0.90 | ×0.90 (launch and install) | Never-crash standard, hostile-param fuzzing, panic guard; but open startup crash on Intel UHD (#170), freezes (#77, #59, #31), Windows writes fail without admin (#218), installer conflicts with other Crafting Apps (#64, #73, #106, #51); 5 days of field history |
|
||||
| Exchanging files with Word users | ×0.90 | ×0.92 (opening files people send) | Word opens our .docx; `.doc` import; but no real-world corpus test, charts/SmartArt/OLE dropped silently, formatting revisions lost (#41), pagination differs (Aptos, no column balancing or footnote continuation). Simple documents, which are most of what a casual user receives, come through well; double-click open on macOS is fixed on main but not yet released (#223, #279) |
|
||||
| Exchanging files with Word users | ×0.90 | ×0.92 (opening files people send) | Word opens our .docx; `.doc` import; but no real-world corpus test, charts/SmartArt/OLE dropped silently, move tracking lost, pagination differs (Aptos, no column balancing or footnote continuation). Simple documents, which are most of what a casual user receives, come through well; double-click open on macOS is fixed on main but not yet released (#223, #279) |
|
||||
| Product | **×0.753** | **×0.787** | |
|
||||
|
||||
### Full target (ready for real work): ~60%
|
||||
|
|
@ -188,7 +188,7 @@ Word. Their weighted sum is the **full ready for real work** number.
|
|||
| Dimension | Weight | Parity | Hours to full | Doc | Evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| Features (depth, table above) | 30% | 68% | 210–335 | this file | Weighted table above. Hours: the area table sums to 290–460 h; the Page layout, Footnotes, RTL and East Asian rows (~50–85 h) are counted under Layout and the Proofing row (25–45 h) under Localization, so they aren't counted twice |
|
||||
| File formats (DOCX fidelity first) | 20% | 60% | 185–300 | [file-format-parity.md](file-format-parity.md) | DOCX opens in Word and round-trips our own tests, but no real-world corpus test yet; charts, SmartArt, OLE, content controls (unwrapped), formatting revisions and encrypted files are lost or refused |
|
||||
| File formats (DOCX fidelity first) | 20% | 60% | 185–300 | [file-format-parity.md](file-format-parity.md) | DOCX opens in Word and round-trips our own tests, but no real-world corpus test yet; charts, SmartArt, OLE, content controls (unwrapped), move tracking and encrypted files are lost or refused |
|
||||
| Layout and pagination fidelity | 15% | 55% | 90–145 | [layout-parity.md](layout-parity.md) | Aptos has no metric-matched substitute so lines break differently from Word; no column balancing, footnote continuation or document grid; compatibility modes beyond 15 partial |
|
||||
| UI/UX fidelity | 10% | 65% | 65–100 | [ui-parity.md](ui-parity.md) | Ribbon, keytips, mini-toolbar, Backstage, 101 shortcuts; 16 modal dialogs against Word's ~100; no ribbon/keyboard customization |
|
||||
| Stability | 10% | 50% | 25–45 | [gaps.md](gaps.md) | Never-crash standard and hostile-param fuzzing in place, but open reports of a startup crash on Intel UHD (#170), freezes (#77, #59, #31) and Windows write failure without admin (#218); no soak test; 5 days of field history |
|
||||
|
|
@ -249,6 +249,7 @@ can be used without committing Word output.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change |
|
||||
| 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) |
|
||||
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 384/430 → 387/430 (90.0%) |
|
||||
| 2026-10-10 | trivial | Pictures, shapes, text boxes: free rotation and flips, rotation handle, rotated wrap bounds, DOCX `a:xfrm` rot/flipH/flipV (#332); no percentage change |
|
||||
|
|
|
|||
Loading…
Reference in New Issue