mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
Moved text is tracked as a move instead of a deletion plus an insertion: - Model: Revision::move_name links the source (a deletion revision) and the destination (an insertion revision) of a move; runs and paragraph marks keep using ins/del, so everything that hides deletions or exports the result treats the moved-away text like a deletion. - DOCX: w:moveFrom / w:moveTo inside w:moveFromRangeStart/End and w:moveToRangeStart/End (ECMA-376 17.13.5.21-28) are read as linked moves, paragraph marks included; on save the ranges are written around each moved stretch with the move's name, author and date, moved-away text in w:t. A move missing an end, or whose range never ends, opens as a plain deletion and insertion. - Recording: with Track Changes on, a cut of a paragraph or at least a sentence gets a revision of its own; pasting it right after makes both a move (pasted paragraph marks are tracked as inserted too). - Accept/Reject at either end resolves both; the Reviewing Pane lists moveFrom/moveTo with the move name and "Moved from"/"Moved to". - Markup: green, double strikethrough at the source and double underline at the destination; "Moved from"/"Moved to" balloons. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
34 files changed
+668
-55
No files matched your search
+2
-1
@@ -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); move tracking and content controls are lost; there is open field evidence of
|
||||
balancing or footnote continuation); 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 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons |
|
||||
| 2026-10-11 | Footnote and Endnote dialog from the References › Footnotes launcher: options per section or document (below text, end of section, number format, start at, restart each section or page), custom marks, convert footnotes ↔ endnotes; kept in DOCX (#385) |
|
||||
| 2026-10-11 | Shape Format › Text: text direction, Align Text and linked text boxes (Create Link / Break Link), read and written in DOCX (#369) |
|
||||
| 2026-10-11 | Mail-merge recipients from Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets, with Select Table to pick the sheet (#334) |
|
||||
|
||||
@@ -305,6 +305,24 @@ pub struct Revision {
|
||||
pub kind: RevisionKind,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
/// A move (ECMA-376 §17.13.5.21–28): the name linking the source (an `Delete` revision,
|
||||
/// `w:moveFrom`) with the destination (an `Insert` revision, `w:moveTo`). `None` = a plain
|
||||
/// insertion or deletion.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub move_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Revision {
|
||||
pub fn new(kind: RevisionKind, author: impl Into<String>, date: impl Into<String>) -> Revision {
|
||||
Revision { kind, author: author.into(), date: date.into(), move_name: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The move name of revision `idx`, when it is one end of a move.
|
||||
pub fn move_name(&self, idx: Option<u32>) -> Option<&str> {
|
||||
idx.and_then(|i| self.revisions.get(i as usize)).and_then(|r| r.move_name.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// A bibliography source.
|
||||
|
||||
@@ -38,7 +38,9 @@ pub(crate) struct Reader<'p> {
|
||||
pkg: &'p Package,
|
||||
pub doc: Document,
|
||||
pub pc: PropCtx,
|
||||
revs: HashMap<(u8, String, String), u32>,
|
||||
revs: HashMap<(u8, String, String, Option<String>), u32>,
|
||||
/// Move ranges (`w:moveFromRangeStart` …) seen so far.
|
||||
pub moves: MoveCtx,
|
||||
media_by_path: HashMap<String, String>,
|
||||
hf_by_path: HashMap<String, u32>,
|
||||
pub footnotes: HashMap<i64, u32>,
|
||||
@@ -104,6 +106,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
doc,
|
||||
pc: PropCtx::default(),
|
||||
revs: HashMap::new(),
|
||||
moves: MoveCtx::default(),
|
||||
media_by_path: HashMap::new(),
|
||||
hf_by_path: HashMap::new(),
|
||||
footnotes: HashMap::new(),
|
||||
@@ -191,6 +194,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
let manifest = r.embedded.to_bytes();
|
||||
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
|
||||
}
|
||||
r.finish_moves();
|
||||
let links = std::mem::take(&mut r.txbx_links);
|
||||
link_text_boxes(&mut r.doc, links);
|
||||
let mut doc = r.doc;
|
||||
@@ -198,6 +202,81 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Most move ranges open at once, and most move names tracked, per file.
|
||||
const MAX_OPEN_MOVES: usize = 64;
|
||||
const MAX_MOVES: usize = 100_000;
|
||||
|
||||
/// The move ranges of a file (ECMA-376 §17.13.5.22–27): a `w:moveFrom` / `w:moveTo` is one end
|
||||
/// of a move when it sits inside a range of that kind; the range's name links both ends.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MoveCtx {
|
||||
/// Open source and destination ranges: (`w:id`, `w:name`).
|
||||
open: [Vec<(String, String)>; 2],
|
||||
/// Per name: a source range started / ended, a destination range started / ended (bits 0–3).
|
||||
seen: HashMap<String, u8>,
|
||||
/// The range of each kind that ended last in the paragraph being read (for its mark).
|
||||
pub closed_here: [Option<String>; 2],
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// A `w:moveFromRangeStart/End` or `w:moveToRangeStart/End`.
|
||||
pub fn move_range(&mut self, e: &El) {
|
||||
let (side, start) = match e.name.as_str() {
|
||||
"w:moveFromRangeStart" => (0, true),
|
||||
"w:moveFromRangeEnd" => (0, false),
|
||||
"w:moveToRangeStart" => (1, true),
|
||||
"w:moveToRangeEnd" => (1, false),
|
||||
_ => return,
|
||||
};
|
||||
let id = e.attr("w:id").unwrap_or("").chars().take(64).collect::<String>();
|
||||
let m = &mut self.moves;
|
||||
let Some(open) = m.open.get_mut(side) else { return };
|
||||
if start {
|
||||
let name = e.attr("w:name").unwrap_or("").chars().take(256).collect::<String>();
|
||||
if name.is_empty() || open.len() >= MAX_OPEN_MOVES || (m.seen.len() >= MAX_MOVES && !m.seen.contains_key(&name)) {
|
||||
return;
|
||||
}
|
||||
*m.seen.entry(name.clone()).or_default() |= 1 << (side * 2);
|
||||
open.push((id, name));
|
||||
} else if let Some(i) = open.iter().rposition(|(x, _)| *x == id) {
|
||||
let (_, name) = open.remove(i);
|
||||
*m.seen.entry(name.clone()).or_default() |= 2 << (side * 2);
|
||||
if let Some(c) = m.closed_here.get_mut(side) {
|
||||
*c = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The revision of a `w:moveFrom` (`to` false) or `w:moveTo`: one end of the innermost open
|
||||
/// range of its kind, else (no range) a plain deletion or insertion.
|
||||
pub fn move_revision(&mut self, to: bool, e: &El, mark: bool) -> u32 {
|
||||
let side = usize::from(to);
|
||||
let open = self.moves.open.get(side).and_then(|o| o.last()).map(|(_, n)| n.clone());
|
||||
// A paragraph mark comes after its paragraph's text: a range that ended in it counts.
|
||||
let name = if mark { open.or_else(|| self.moves.closed_here.get(side).cloned().flatten()) } else { open };
|
||||
let kind = if to { RevisionKind::Insert } else { RevisionKind::Delete };
|
||||
self.revision_named(kind, e, name)
|
||||
}
|
||||
|
||||
/// Moves missing an end (a source or destination range, or its text) are plain deletions
|
||||
/// and insertions.
|
||||
fn finish_moves(&mut self) {
|
||||
let mut ends: HashMap<&str, u8> = HashMap::new();
|
||||
for r in &self.doc.revisions {
|
||||
if let Some(n) = r.move_name.as_deref() {
|
||||
*ends.entry(n).or_default() |= if r.kind == RevisionKind::Delete { 1 } else { 2 };
|
||||
}
|
||||
}
|
||||
let ok: HashSet<String> =
|
||||
ends.into_iter().filter(|(n, e)| *e == 3 && self.moves.seen.get(*n) == Some(&0b1111)).map(|(n, _)| n.to_string()).collect();
|
||||
for r in &mut self.doc.revisions {
|
||||
if r.move_name.as_ref().is_some_and(|n| !ok.contains(n)) {
|
||||
r.move_name = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Link the text boxes read as (id, seq, part) into chains (see [`Reader::txbx_links`]): each
|
||||
/// id's boxes in `seq` order after the box holding the text. A repeated seq, a chain without its
|
||||
/// first box or more than [`wordcraft_doc::MAX_LINKED_BOXES`] boxes: the extra boxes stay apart.
|
||||
@@ -337,6 +416,10 @@ impl Reader<'_> {
|
||||
}
|
||||
|
||||
pub fn revision(&mut self, kind: RevisionKind, e: &El) -> u32 {
|
||||
self.revision_named(kind, e, None)
|
||||
}
|
||||
|
||||
fn revision_named(&mut self, kind: RevisionKind, e: &El, move_name: Option<String>) -> u32 {
|
||||
let author = e.attr("w:author").unwrap_or("").chars().take(256).collect::<String>();
|
||||
let date = e.attr("w:date").unwrap_or("").chars().take(64).collect::<String>();
|
||||
let k = match kind {
|
||||
@@ -344,12 +427,12 @@ impl Reader<'_> {
|
||||
RevisionKind::Delete => 1,
|
||||
RevisionKind::Format => 2,
|
||||
};
|
||||
let key = (k, author.clone(), date.clone());
|
||||
let key = (k, author.clone(), date.clone(), move_name.clone());
|
||||
if let Some(i) = self.revs.get(&key) {
|
||||
return *i;
|
||||
}
|
||||
let i = self.doc.revisions.len() as u32;
|
||||
self.doc.revisions.push(Revision { kind, author, date });
|
||||
self.doc.revisions.push(Revision { kind, author, date, move_name });
|
||||
self.revs.insert(key, i);
|
||||
i
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ impl Reader<'_> {
|
||||
sc.pending.push(o);
|
||||
}
|
||||
}
|
||||
"w:moveFromRangeStart" | "w:moveFromRangeEnd" | "w:moveToRangeStart" | "w:moveToRangeEnd" => self.move_range(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -254,6 +255,8 @@ impl Reader<'_> {
|
||||
fn read_para(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, depth: usize) -> Paragraph {
|
||||
let mut pb = PB::default();
|
||||
let mut para = Paragraph::new();
|
||||
let mut mark_moves: [Option<&El>; 2] = [None, None];
|
||||
self.moves.closed_here = [None, None];
|
||||
for o in std::mem::take(&mut sc.pending) {
|
||||
pb.push_obj(o, &CharProps::default());
|
||||
}
|
||||
@@ -266,8 +269,12 @@ impl Reader<'_> {
|
||||
if let Some(rpr) = ppr.child("w:rPr") {
|
||||
for k in rpr.els() {
|
||||
match k.name.as_str() {
|
||||
"w:ins" | "w:moveTo" => para.mark.ins = Some(self.revision(RevisionKind::Insert, k)),
|
||||
"w:del" | "w:moveFrom" => para.mark.del = Some(self.revision(RevisionKind::Delete, k)),
|
||||
"w:ins" => para.mark.ins = Some(self.revision(RevisionKind::Insert, k)),
|
||||
"w:del" => para.mark.del = Some(self.revision(RevisionKind::Delete, k)),
|
||||
// A moved paragraph mark (§17.13.5.21, §17.13.5.24): its move range
|
||||
// usually starts in the paragraph's content, so it is read after that.
|
||||
"w:moveTo" => mark_moves[1] = Some(k),
|
||||
"w:moveFrom" => mark_moves[0] = Some(k),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -292,6 +299,12 @@ impl Reader<'_> {
|
||||
let ctx = RunCtx::default();
|
||||
self.read_inline_children(sc, &mut pb, e, rels, &ctx, depth + 1);
|
||||
self.spill_fields(sc, &mut pb);
|
||||
if let Some(k) = mark_moves[0] {
|
||||
para.mark.del = Some(self.move_revision(false, k, true));
|
||||
}
|
||||
if let Some(k) = mark_moves[1] {
|
||||
para.mark.ins = Some(self.move_revision(true, k, true));
|
||||
}
|
||||
para.text = pb.text;
|
||||
para.runs = pb.runs;
|
||||
para.objects = pb.objects;
|
||||
@@ -357,14 +370,15 @@ impl Reader<'_> {
|
||||
}
|
||||
"w:ins" | "w:moveTo" => {
|
||||
let mut c = ctx.clone();
|
||||
c.ins = Some(self.revision(RevisionKind::Insert, k));
|
||||
c.ins = Some(if k.name == "w:moveTo" { self.move_revision(true, k, false) } else { self.revision(RevisionKind::Insert, k) });
|
||||
self.read_inline_children(sc, pb, k, rels, &c, depth + 1);
|
||||
}
|
||||
"w:del" | "w:moveFrom" => {
|
||||
let mut c = ctx.clone();
|
||||
c.del = Some(self.revision(RevisionKind::Delete, k));
|
||||
c.del = Some(if k.name == "w:moveFrom" { self.move_revision(false, k, false) } else { self.revision(RevisionKind::Delete, k) });
|
||||
self.read_inline_children(sc, pb, k, rels, &c, depth + 1);
|
||||
}
|
||||
"w:moveFromRangeStart" | "w:moveFromRangeEnd" | "w:moveToRangeStart" | "w:moveToRangeEnd" => self.move_range(k),
|
||||
"w:fldSimple" => {
|
||||
let instr = k.attr("w:instr").unwrap_or("").to_string();
|
||||
let props = k.child("w:r").and_then(|r| r.child("w:rPr")).map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx));
|
||||
|
||||
@@ -68,6 +68,8 @@ pub(crate) struct Writer<'d> {
|
||||
media_files: BTreeMap<String, String>,
|
||||
bookmarks: HashMap<String, u32>,
|
||||
next_rev_id: u32,
|
||||
/// Move ranges open in the output: (revision, range `w:id`, destination?).
|
||||
open_moves: Vec<(u32, String, bool)>,
|
||||
docpr: u32,
|
||||
z: u32,
|
||||
/// Note part id → file note id, in first-reference order.
|
||||
@@ -125,6 +127,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
|
||||
media_files: BTreeMap::new(),
|
||||
bookmarks: HashMap::new(),
|
||||
next_rev_id: 0,
|
||||
open_moves: Vec::new(),
|
||||
docpr: 0,
|
||||
z: 251_658_240,
|
||||
footnotes: Vec::new(),
|
||||
|
||||
@@ -58,11 +58,52 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth == 0 {
|
||||
self.move_ranges(w, &[]);
|
||||
}
|
||||
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
|
||||
w.raw("<w:p/>");
|
||||
}
|
||||
}
|
||||
|
||||
/// The moves (revision, destination?) in `props`.
|
||||
fn moves_of(&self, props: &CharProps) -> Vec<(u32, bool)> {
|
||||
[(props.del, false), (props.ins, true)]
|
||||
.into_iter()
|
||||
.filter_map(|(r, to)| r.filter(|r| self.doc.move_name(Some(*r)).is_some()).map(|r| (r, to)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bring the open move ranges to `want` (ECMA-376 §17.13.5.22–27): end the ranges of moves
|
||||
/// that stop here, start those that begin. A range is written around each stretch of a
|
||||
/// move's text (and paragraph marks), with the move's name, author and date.
|
||||
fn move_ranges(&mut self, w: &mut W, want: &[(u32, bool)]) {
|
||||
let mut k = 0;
|
||||
while k < self.open_moves.len() {
|
||||
match self.open_moves.get(k) {
|
||||
Some((rev, id, to)) if !want.contains(&(*rev, *to)) => {
|
||||
w.empty(if *to { "w:moveToRangeEnd" } else { "w:moveFromRangeEnd" }, &[("w:id", id)]);
|
||||
self.open_moves.remove(k);
|
||||
}
|
||||
_ => k += 1,
|
||||
}
|
||||
}
|
||||
for &(rev, to) in want {
|
||||
if self.open_moves.iter().any(|(r, _, t)| *r == rev && *t == to) {
|
||||
continue;
|
||||
}
|
||||
let name = self.doc.move_name(Some(rev)).unwrap_or("").to_string();
|
||||
let (id, author, date) = self.rev_attrs(rev);
|
||||
let tag = if to { "w:moveToRangeStart" } else { "w:moveFromRangeStart" };
|
||||
if date.is_empty() {
|
||||
w.empty(tag, &[("w:id", &id), ("w:name", &name), ("w:author", &author)]);
|
||||
} else {
|
||||
w.empty(tag, &[("w:id", &id), ("w:name", &name), ("w:author", &author), ("w:date", &date)]);
|
||||
}
|
||||
self.open_moves.push((rev, id, to));
|
||||
}
|
||||
}
|
||||
|
||||
fn para(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize) {
|
||||
// A drop cap is a separate framed paragraph holding the first character.
|
||||
if let Some(lines) = p.props.drop_cap.filter(|l| *l > 0)
|
||||
@@ -99,6 +140,7 @@ impl Writer<'_> {
|
||||
let mark_revs: Vec<(u32, &str)> = [(p.mark.ins, "w:ins"), (p.mark.del, "w:del")]
|
||||
.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)))
|
||||
.map(|(i, tag)| (i, self.rev_tag(i, tag)))
|
||||
.collect();
|
||||
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() {
|
||||
@@ -162,6 +204,9 @@ impl Writer<'_> {
|
||||
if toc_end {
|
||||
w.raw(r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#);
|
||||
}
|
||||
// Move ranges stay open across a moved paragraph mark; others end with the paragraph.
|
||||
let mark_moves = self.moves_of(&p.mark);
|
||||
self.move_ranges(w, &mark_moves);
|
||||
w.close("w:p");
|
||||
}
|
||||
|
||||
@@ -241,6 +286,8 @@ impl Writer<'_> {
|
||||
j += 1;
|
||||
}
|
||||
let del = self.is_del(props);
|
||||
let moves = self.moves_of(props);
|
||||
self.move_ranges(w, &moves);
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
@@ -270,8 +317,18 @@ impl Writer<'_> {
|
||||
idx.and_then(|i| self.doc.revisions.get(i as usize)).map(|r| r.kind)
|
||||
}
|
||||
|
||||
/// Deleted text goes in `w:delText`; text moved away stays in `w:t` (§17.13.5.22).
|
||||
fn is_del(&self, props: &CharProps) -> bool {
|
||||
props.del.is_some() && self.rev_kind(props.del) != Some(RevisionKind::Format)
|
||||
props.del.is_some() && self.rev_kind(props.del) != Some(RevisionKind::Format) && self.doc.move_name(props.del).is_none()
|
||||
}
|
||||
|
||||
/// `w:ins` / `w:del`, or `w:moveTo` / `w:moveFrom` when revision `idx` is one end of a move.
|
||||
fn rev_tag(&self, idx: u32, tag: &'static str) -> &'static str {
|
||||
match (self.doc.move_name(Some(idx)).is_some(), tag) {
|
||||
(true, "w:ins") => "w:moveTo",
|
||||
(true, "w:del") => "w:moveFrom",
|
||||
_ => tag,
|
||||
}
|
||||
}
|
||||
|
||||
fn rev_attrs(&mut self, idx: u32) -> (String, String, String) {
|
||||
@@ -316,6 +373,7 @@ impl Writer<'_> {
|
||||
if self.rev_kind(Some(idx)) == Some(RevisionKind::Format) {
|
||||
continue;
|
||||
}
|
||||
let tag = self.rev_tag(idx, tag);
|
||||
let (id, author, date) = self.rev_attrs(idx);
|
||||
if date.is_empty() {
|
||||
w.open(tag, &[("w:id", &id), ("w:author", &author)]);
|
||||
@@ -327,8 +385,10 @@ impl Writer<'_> {
|
||||
|
||||
fn rev_close(&mut self, w: &mut W, props: &CharProps) {
|
||||
for (idx, tag) in [(props.del, "w:del"), (props.ins, "w:ins")] {
|
||||
if idx.is_some() && self.rev_kind(idx) != Some(RevisionKind::Format) {
|
||||
w.close(tag);
|
||||
if let Some(i) = idx
|
||||
&& self.rev_kind(idx) != Some(RevisionKind::Format)
|
||||
{
|
||||
w.close(self.rev_tag(i, tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::io::Write;
|
||||
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind};
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||||
|
||||
@@ -1082,6 +1082,49 @@ fn formatting_revisions_round_trip() {
|
||||
assert_eq!(again.revisions, d.revisions);
|
||||
}
|
||||
|
||||
/// Issue #400: moved text (ECMA-376 §17.13.5.21–28). `w:moveFrom` / `w:moveTo` inside named
|
||||
/// move ranges are the two ends of one move, paragraph marks included; the ranges are written
|
||||
/// back around them. A move missing an end, or whose range never ends, is a plain deletion and
|
||||
/// insertion.
|
||||
#[test]
|
||||
fn move_revisions_round_trip() {
|
||||
let body = r#"
|
||||
<w:p><w:pPr><w:rPr><w:moveTo w:id="1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/></w:rPr></w:pPr><w:moveToRangeStart w:id="2" w:name="move1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/><w:moveTo w:id="3" w:author="Ana" w:date="2026-10-03T00:00:00Z"><w:r><w:t>Moved here.</w:t></w:r></w:moveTo></w:p>
|
||||
<w:moveToRangeEnd w:id="2"/>
|
||||
<w:p><w:r><w:t xml:space="preserve">Stays. </w:t></w:r><w:moveFromRangeStart w:id="4" w:name="move1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/><w:moveFrom w:id="5" w:author="Ana" w:date="2026-10-03T00:00:00Z"><w:r><w:t>Moved here.</w:t></w:r></w:moveFrom><w:moveFromRangeEnd w:id="4"/></w:p>
|
||||
<w:p><w:moveFromRangeStart w:id="6" w:name="lonely" w:author="Bo"/><w:moveFrom w:id="7" w:author="Bo"><w:r><w:t>No destination</w:t></w:r></w:moveFrom><w:moveFromRangeEnd w:id="6"/><w:moveToRangeStart w:id="8" w:name="open" w:author="Bo"/><w:moveTo w:id="9" w:author="Bo"><w:r><w:t>never ends</w:t></w:r></w:moveTo><w:moveTo w:id="10" w:author="Cy"><w:r><w:t>no range</w:t></w:r></w:moveTo><w:moveFromRangeEnd w:id="404"/></w:p>"#;
|
||||
let check = |d: &Document| {
|
||||
let p = paras(d);
|
||||
let name = |r: Option<u32>| d.move_name(r).map(str::to_string);
|
||||
assert_eq!(name(p[0].mark.ins).as_deref(), Some("move1"), "a moved paragraph mark");
|
||||
let to = p[0].runs[0].props.ins;
|
||||
assert_eq!((name(to).as_deref(), d.revisions[to.unwrap() as usize].kind), (Some("move1"), RevisionKind::Insert));
|
||||
let from = p[1].run_ranges().find(|(_, c)| c.del.is_some()).map(|(_, c)| c.del).unwrap();
|
||||
assert_eq!((name(from).as_deref(), d.revisions[from.unwrap() as usize].kind), (Some("move1"), RevisionKind::Delete));
|
||||
assert_eq!(d.revisions[from.unwrap() as usize].author, "Ana");
|
||||
// Hostile ends: each is a plain revision, still tracked.
|
||||
for (r, c) in p[2].run_ranges() {
|
||||
assert!(c.ins.or(c.del).is_some(), "{r:?} keeps its revision");
|
||||
assert_eq!(name(c.ins.or(c.del)), None, "{r:?} is no move");
|
||||
}
|
||||
};
|
||||
let d = read_body(body);
|
||||
check(&d);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = text(&unzip(&out), "word/document.xml");
|
||||
assert_eq!(attr_values(&xml, "w:moveFromRangeStart", "w:name"), ["move1"]);
|
||||
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:name"), ["move1"]);
|
||||
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:author"), ["Ana"]);
|
||||
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:id"), attr_values(&xml, "w:moveToRangeEnd", "w:id"));
|
||||
assert_eq!(attr_values(&xml, "w:moveFromRangeStart", "w:id"), attr_values(&xml, "w:moveFromRangeEnd", "w:id"));
|
||||
assert!(xml.contains("<w:moveFrom ") && !xml.contains("<w:delText xml:space=\"preserve\">Moved"), "moved-away text stays in w:t");
|
||||
// The range around the moved paragraph includes its mark: it ends in the next paragraph.
|
||||
let to_start = xml.find("<w:moveToRangeStart").unwrap();
|
||||
let to_end = xml.find("<w:moveToRangeEnd").unwrap();
|
||||
assert!(xml[to_start..to_end].contains("</w:p>"), "{xml}");
|
||||
check(&wordcraft_docx::read(&out).unwrap());
|
||||
}
|
||||
|
||||
/// A document's own footnote separators and continuation notice are kept (ECMA-376 §17.11), and
|
||||
/// written back as Word numbers them: -1, 0 and 1, the notes after them.
|
||||
#[test]
|
||||
|
||||
@@ -769,8 +769,8 @@ fn toc_field_skips_nested_stories() {
|
||||
#[test]
|
||||
fn tracked_changes_round_trip() {
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
d.settings.track_changes = true;
|
||||
let ins = CharProps { ins: Some(0), ..Default::default() };
|
||||
let del = CharProps { del: Some(1), bold: Some(true), ..Default::default() };
|
||||
@@ -794,8 +794,8 @@ fn tracked_paragraph_marks_round_trip() {
|
||||
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
|
||||
// `w:rPr` (ECMA-376 §17.13.5.16, §17.13.5.15), alone or with mark formatting.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
let mut split = Paragraph::with_text("Owned ALPHA ", CharProps::default());
|
||||
split.mark.ins = Some(0);
|
||||
let mut joined = Paragraph::with_text("Owned BETA", CharProps::default());
|
||||
@@ -1156,7 +1156,7 @@ fn kitchen_sink() -> Document {
|
||||
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
|
||||
let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]);
|
||||
d.comments.insert(3, Comment { author: "X".into(), part: c, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "A".into(), date: String::new() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "A".into(), date: String::new(), move_name: None });
|
||||
d.last_section.headers.default = Some(h);
|
||||
let mut blocks: Blocks = Vec::new();
|
||||
let mut p = para_runs(&[
|
||||
|
||||
@@ -109,7 +109,10 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
|
||||
fn cut(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = copy(s, v)?;
|
||||
delete_selection(s)?;
|
||||
// A tracked cut of a paragraph or sentence may become a move when pasted.
|
||||
if !super::moves::cut_tracked(s)? {
|
||||
delete_selection(s)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Recorder<'_> {
|
||||
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.push(Revision { kind: RevisionKind::Format, author: self.author.clone(), date: self.date.clone(), move_name: None });
|
||||
self.revisions.len().saturating_sub(1) as u32
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod insert;
|
||||
pub mod inspector;
|
||||
pub mod lists;
|
||||
pub mod mailings;
|
||||
pub mod moves;
|
||||
pub mod objects;
|
||||
pub mod page;
|
||||
pub mod para;
|
||||
@@ -116,10 +117,19 @@ pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
/// A revision record for the current author.
|
||||
pub fn new_revision(s: &mut Session, kind: RevisionKind) -> u32 {
|
||||
let date = now_iso();
|
||||
if let Some((i, _)) = s.doc.revisions.iter().enumerate().rev().find(|(_, r)| r.kind == kind && r.author == s.author && r.date == date) {
|
||||
// Never one end of a move, nor the deletion a cut may still turn into one.
|
||||
let held = s.pending_move.as_ref().map(|m| m.rev);
|
||||
if let Some((i, _)) = s
|
||||
.doc
|
||||
.revisions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(i, r)| r.kind == kind && r.author == s.author && r.date == date && r.move_name.is_none() && held != Some(*i as u32))
|
||||
{
|
||||
return i as u32;
|
||||
}
|
||||
s.doc.revisions.push(Revision { kind, author: s.author.clone(), date });
|
||||
s.doc.revisions.push(Revision { kind, author: s.author.clone(), date, move_name: None });
|
||||
(s.doc.revisions.len() - 1) as u32
|
||||
}
|
||||
|
||||
@@ -150,6 +160,11 @@ pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Pa
|
||||
/// Tracked deletion: own insertions are removed, other text is marked deleted.
|
||||
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
|
||||
let rid = new_revision(s, RevisionKind::Delete);
|
||||
track_delete_as(s, a, b, rid)
|
||||
}
|
||||
|
||||
/// [`track_delete`] marking the deleted text with revision `rid`.
|
||||
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<Pos, CmdError> {
|
||||
let author = s.author.clone();
|
||||
// Remove text this author inserted (it never existed for the reader); mark the rest.
|
||||
let paths = s.doc.paths_between(a, b);
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Tracked moves (Review › Track Changes, ECMA-376 §17.13.5.21–28): with Track Changes on, a
|
||||
//! paragraph or sentence cut and pasted elsewhere is recorded as a move, its source (`w:moveFrom`,
|
||||
//! a deletion revision) and destination (`w:moveTo`, an insertion revision) linked by a move
|
||||
//! name. Accepting or rejecting either end resolves both.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Pos, Revision, RevisionKind, StoryRef};
|
||||
|
||||
use super::{now_iso, track_delete_as};
|
||||
use crate::{CmdError, Selection, Session};
|
||||
|
||||
/// The deletion the last tracked cut recorded, which the paste right after it makes a move.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PendingMove {
|
||||
/// Its revision (made for this cut alone).
|
||||
pub rev: u32,
|
||||
/// The text that was cut.
|
||||
pub text: String,
|
||||
pub author: String,
|
||||
}
|
||||
|
||||
/// Whether cut text is big enough to be moved rather than deleted and inserted, as Word does: a
|
||||
/// whole paragraph (it ends in, or holds, a paragraph mark) or at least a sentence.
|
||||
pub fn move_sized(text: &str) -> bool {
|
||||
let t = text.trim_end_matches([' ', '\u{a0}']);
|
||||
if t.contains('\n') {
|
||||
return true;
|
||||
}
|
||||
let t = t.trim_end_matches(['"', '\'', '\u{201d}', '\u{2019}', ')']);
|
||||
t.ends_with(['.', '!', '?', '\u{2026}']) && t.split_whitespace().nth(1).is_some()
|
||||
}
|
||||
|
||||
/// Every story.
|
||||
fn stories(s: &Session) -> Vec<StoryRef> {
|
||||
std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect()
|
||||
}
|
||||
|
||||
/// Whether some text or paragraph mark carries revision `rev` as its insertion or deletion.
|
||||
fn in_use(s: &Session, rev: u32) -> bool {
|
||||
stories(s).into_iter().any(|st| {
|
||||
s.doc.para_paths(st).iter().any(|path| {
|
||||
s.doc.para(st, path).is_some_and(|p| {
|
||||
p.mark.ins == Some(rev) || p.mark.del == Some(rev) || p.runs.iter().any(|r| r.props.ins == Some(rev) || r.props.del == Some(rev))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Cut's deletion under Track Changes: a cut big enough to be moved gets a revision of its own
|
||||
/// and is remembered, so the paste right after it can make it a move. Returns `false` (nothing
|
||||
/// done) when it isn't such a cut.
|
||||
pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if !s.doc.settings.track_changes || a == b || a.story != b.story || !move_sized(&s.clipboard_text) {
|
||||
return Ok(false);
|
||||
}
|
||||
s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso()));
|
||||
let rid = (s.doc.revisions.len() - 1) as u32;
|
||||
let at = track_delete_as(s, &a, &b, rid)?;
|
||||
s.sel = Selection::caret(at);
|
||||
// Only the author's own insertions were cut: they are gone, not moved.
|
||||
if in_use(s, rid) {
|
||||
s.pending_move = Some(PendingMove { rev: rid, text: s.clipboard_text.clone(), author: s.author.clone() });
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A tracked paste of what the last command cut: makes that cut the source of a move and
|
||||
/// returns the revision for the pasted text (the destination). `None` = an ordinary insertion.
|
||||
pub fn take_paste(s: &mut Session, frag: &Fragment) -> Option<u32> {
|
||||
let m = s.pending_move.take()?;
|
||||
if m.author != s.author || frag.plain_text() != m.text || !in_use(s, m.rev) {
|
||||
return None;
|
||||
}
|
||||
let src = s.doc.revisions.get(m.rev as usize).filter(|r| r.kind == RevisionKind::Delete && r.move_name.is_none())?;
|
||||
let date = src.date.clone();
|
||||
let taken: BTreeSet<&str> = s.doc.revisions.iter().filter_map(|r| r.move_name.as_deref()).collect();
|
||||
let name = (1..).map(|n: u64| format!("move{n}")).find(|n| !taken.contains(n.as_str()))?;
|
||||
if let Some(r) = s.doc.revisions.get_mut(m.rev as usize) {
|
||||
r.move_name = Some(name.clone());
|
||||
}
|
||||
s.doc.revisions.push(Revision { kind: RevisionKind::Insert, author: s.author.clone(), date, move_name: Some(name) });
|
||||
Some((s.doc.revisions.len() - 1) as u32)
|
||||
}
|
||||
|
||||
/// The move names of the insertions and deletions in `a..b` (and of the paragraph marks it
|
||||
/// covers; `caret_mark` = the mark of the paragraph at that path, for a caret before it).
|
||||
pub fn names_in(s: &Session, a: &Pos, b: &Pos, caret_mark: Option<&wordcraft_doc::Path>) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
let mut add = |r: Option<u32>| {
|
||||
if let Some(n) = s.doc.move_name(r) {
|
||||
out.insert(n.to_string());
|
||||
}
|
||||
};
|
||||
for path in s.doc.paths_between(a, b) {
|
||||
let Some(p) = s.doc.para(a.story, &path) else { continue };
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
let to = if path == b.path { b.off } else { p.len() };
|
||||
for (r, c) in p.run_ranges() {
|
||||
if (r.end > from && r.start < to) || (from == to && r.start <= from && from <= r.end) {
|
||||
add(c.ins);
|
||||
add(c.del);
|
||||
}
|
||||
}
|
||||
if path != b.path || caret_mark == Some(&path) {
|
||||
add(p.mark.ins);
|
||||
add(p.mark.del);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Accept or reject every end of the moves named `names`: accepting removes the source and
|
||||
/// keeps the destination, rejecting removes the destination and restores the source.
|
||||
pub fn resolve(s: &mut Session, names: &BTreeSet<String>, accept: bool) -> Result<(), CmdError> {
|
||||
if names.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let named = |s: &Session, r: Option<u32>| s.doc.move_name(r).is_some_and(|n| names.contains(n));
|
||||
for st in stories(s) {
|
||||
for path in s.doc.para_paths(st).into_iter().rev() {
|
||||
let Some(p) = s.doc.para(st, &path) else { continue };
|
||||
let ranges: Vec<(usize, usize, bool, bool)> =
|
||||
p.run_ranges().map(|(r, c)| (r.start, r.end, named(s, c.ins), named(s, c.del))).filter(|(_, _, ins, del)| *ins || *del).collect();
|
||||
let (mark_ins, mark_del) = (named(s, p.mark.ins), named(s, p.mark.del));
|
||||
if ranges.is_empty() && !mark_ins && !mark_del {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(st, &path)?;
|
||||
for (a, b, ins, del) in ranges.into_iter().rev() {
|
||||
if (del && accept) || (ins && !accept) {
|
||||
para.delete(a, b)?;
|
||||
} else {
|
||||
para.format(a, b, &|c| {
|
||||
if ins {
|
||||
c.ins = None;
|
||||
}
|
||||
if del {
|
||||
c.del = None;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if !mark_ins && !mark_del {
|
||||
continue;
|
||||
}
|
||||
// A moved paragraph mark that goes away joins the next paragraph to this one.
|
||||
if ((mark_del && accept) || (mark_ins && !accept)) && super::join_next_para(s, st, &path)? {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(st, &path)?;
|
||||
if mark_ins {
|
||||
para.mark.ins = None;
|
||||
}
|
||||
if mark_del {
|
||||
para.mark.del = None;
|
||||
}
|
||||
para.touch();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the body has moved text (cheap: stops at the first).
|
||||
pub fn has_moves(doc: &wordcraft_doc::Document) -> bool {
|
||||
if !doc.revisions.iter().any(|r| r.move_name.is_some()) {
|
||||
return false;
|
||||
}
|
||||
doc.para_paths(StoryRef::Body).iter().any(|path| {
|
||||
doc.para(StoryRef::Body, path).is_some_and(|p| {
|
||||
doc.move_name(p.mark.ins).is_some()
|
||||
|| doc.move_name(p.mark.del).is_some()
|
||||
|| p.runs.iter().any(|r| doc.move_name(r.props.ins).is_some() || doc.move_name(r.props.del).is_some())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::{Document, Pos};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn texts(s: &Session) -> Vec<String> {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()
|
||||
}
|
||||
|
||||
/// Three paragraphs; with Track Changes on, the second (with its mark) cut and pasted
|
||||
/// before the first.
|
||||
fn moved() -> Session {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap();
|
||||
s.run("file.setAuthor", &json!({"name": "Ana"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap();
|
||||
s.run("edit.cut", &json!({})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap();
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn kinds(s: &mut Session) -> Vec<(String, Value)> {
|
||||
let ch = s.run("review.changes", &json!({})).unwrap();
|
||||
ch.as_array().unwrap().iter().map(|c| (c["kind"].as_str().unwrap().to_string(), c["move"].clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_cut_and_paste_of_a_paragraph_is_a_move() {
|
||||
let mut s = moved();
|
||||
assert_eq!(texts(&s), ["Second one.", "First one.", "Second one.", "Third one."]);
|
||||
let p = |i: usize| s.doc.para_at(&Pos::body(i, 0)).unwrap().clone();
|
||||
let name = |r: Option<u32>| s.doc.move_name(r).map(str::to_string);
|
||||
let (to, from) = (p(0), p(2));
|
||||
assert_eq!(name(to.runs[0].props.ins).as_deref(), Some("move1"));
|
||||
assert_eq!(name(to.mark.ins).as_deref(), Some("move1"), "the pasted paragraph mark is moved too");
|
||||
assert_eq!(name(from.runs[0].props.del).as_deref(), Some("move1"));
|
||||
assert_eq!(name(from.mark.del).as_deref(), Some("move1"));
|
||||
assert_eq!(p(1).runs[0].props.ins, None);
|
||||
let k = kinds(&mut s);
|
||||
assert!(k.iter().all(|(k, m)| k.starts_with("move") && m == "move1"), "{k:?}");
|
||||
// Saved and opened again, it is still a move.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let b0 = back.body[0].as_para().unwrap();
|
||||
assert!(back.move_name(b0.runs[0].props.ins).is_some() && back.move_name(b0.mark.ins).is_some());
|
||||
let b2 = back.body[2].as_para().unwrap();
|
||||
assert_eq!(back.move_name(b2.runs[0].props.del), back.move_name(b0.runs[0].props.ins));
|
||||
|
||||
// A word is no move: a deletion and an insertion.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("document.setText", &json!({"text": "one two"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 4}})).unwrap();
|
||||
s.run("edit.cut", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
let k: Vec<String> = kinds(&mut s).into_iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(k, ["delete", "insert"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepting_one_end_of_a_move_accepts_both() {
|
||||
let mut s = moved();
|
||||
// The caret in the text moved away.
|
||||
s.run("caret.set", &json!({"pos": {"block": 2, "off": 3}})).unwrap();
|
||||
s.run("review.accept", &json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["Second one.", "First one.", "Third one."]);
|
||||
assert!(kinds(&mut s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejecting_a_move_puts_the_text_back() {
|
||||
let mut s = moved();
|
||||
// The caret in the moved text.
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 3}})).unwrap();
|
||||
s.run("review.reject", &json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["First one.", "Second one.", "Third one."]);
|
||||
assert!(kinds(&mut s).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -129,19 +129,41 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
|
||||
pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
|
||||
let at = delete_selection(s)?;
|
||||
if s.doc.settings.track_changes {
|
||||
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
|
||||
let track = s.doc.settings.track_changes;
|
||||
let mut rid = None;
|
||||
if track {
|
||||
// What the last command cut, pasted again: the two ends of a move.
|
||||
let r = match super::moves::take_paste(s, &frag) {
|
||||
Some(r) => r,
|
||||
None => super::new_revision(s, wordcraft_doc::RevisionKind::Insert),
|
||||
};
|
||||
rid = Some(r);
|
||||
for b in &mut frag.blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
for r in &mut p.runs {
|
||||
r.props.ins = Some(rid);
|
||||
r.props.del = None;
|
||||
r.props.fmt_change = None;
|
||||
for run in &mut p.runs {
|
||||
run.props.ins = Some(r);
|
||||
run.props.del = None;
|
||||
run.props.fmt_change = None;
|
||||
}
|
||||
p.mark.ins = None;
|
||||
p.mark.del = None;
|
||||
});
|
||||
}
|
||||
}
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
// The paragraph marks the paste added are inserted too.
|
||||
if let Some(r) = rid
|
||||
&& at.story == end.story
|
||||
&& at.path.parent() == end.path.parent()
|
||||
{
|
||||
for i in at.path.last()..end.path.last() {
|
||||
if let Ok(p) = s.doc.para_mut(at.story, &at.path.with_last(i)) {
|
||||
p.mark.ins = Some(r);
|
||||
p.mark.del = None;
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -370,18 +370,48 @@ impl Change {
|
||||
/// 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<Change> {
|
||||
let p = s.doc.para(story, path)?;
|
||||
let kind = if p.mark.ins.is_some() {
|
||||
"insert"
|
||||
} else if p.mark.del.is_some() {
|
||||
"delete"
|
||||
} else {
|
||||
let kind = run_kind(&s.doc, p.mark.ins, p.mark.del);
|
||||
if kind.is_empty() {
|
||||
return None;
|
||||
};
|
||||
}
|
||||
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(Change { a, b, kind, rev: p.mark.ins.or(p.mark.del), props: Vec::new() })
|
||||
}
|
||||
|
||||
/// The kind of an insertion / deletion change: `insert`, `delete`, or for one end of a move
|
||||
/// `moveTo` / `moveFrom`; empty when neither.
|
||||
fn run_kind(doc: &wordcraft_doc::Document, ins: Option<u32>, del: Option<u32>) -> &'static str {
|
||||
match (ins, del) {
|
||||
(Some(_), _) if doc.move_name(ins).is_some() => "moveTo",
|
||||
(Some(_), _) => "insert",
|
||||
(None, Some(_)) if doc.move_name(del).is_some() => "moveFrom",
|
||||
(None, Some(_)) => "delete",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// The moves in the body, for balloons: where each end starts, its author, and whether it is
|
||||
/// the source (`true`) or the destination.
|
||||
pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for c in changes(s) {
|
||||
let from = match c.kind {
|
||||
"moveFrom" => true,
|
||||
"moveTo" => false,
|
||||
_ => continue,
|
||||
};
|
||||
let rev = c.rev.and_then(|r| s.doc.revisions.get(r as usize));
|
||||
let name = rev.and_then(|r| r.move_name.clone()).unwrap_or_default();
|
||||
// One balloon per end (an end runs on across paragraphs).
|
||||
if seen.insert((name, from)) {
|
||||
out.push((c.a, rev.map(|r| r.author.clone()).unwrap_or_default(), from));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
|
||||
for st in stories {
|
||||
@@ -438,6 +468,8 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
|
||||
} else {
|
||||
(a, b)
|
||||
};
|
||||
// Moves with an end in the range: their other ends go with them.
|
||||
let moves = super::moves::names_in(s, &a, &b, caret_mark.as_ref());
|
||||
for path in s.doc.paths_between(&a, &b).into_iter().rev() {
|
||||
let len = s.doc.para(a.story, &path).map(|p| p.len()).unwrap_or(0);
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
@@ -450,6 +482,7 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
|
||||
resolve_cell(s, a.story, &path, accept)?;
|
||||
}
|
||||
}
|
||||
super::moves::resolve(s, &moves, accept)?;
|
||||
s.sel = Selection::caret(a);
|
||||
s.clamp_selection();
|
||||
sel_result(s)
|
||||
@@ -480,13 +513,7 @@ fn changes(s: &Session) -> Vec<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 {
|
||||
""
|
||||
};
|
||||
let kind = run_kind(&s.doc, c.ins, 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() });
|
||||
}
|
||||
@@ -578,6 +605,11 @@ fn list_changes(s: &mut Session, _: &Value) -> CmdResult {
|
||||
v["description"] = json!(fmt_revisions::describe(&c.props));
|
||||
v["props"] = json!(c.props);
|
||||
}
|
||||
// One end of a move: its name links it with the other end.
|
||||
if let Some(name) = rev.and_then(|r| r.move_name.as_deref()).filter(|_| c.kind.starts_with("move")) {
|
||||
v["move"] = json!(name);
|
||||
v["description"] = json!(if c.kind == "moveFrom" { "Moved from" } else { "Moved to" });
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect(),
|
||||
|
||||
@@ -608,9 +608,9 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let new: Vec<Paragraph> = revised.body.iter().filter_map(|b| b.as_para().cloned()).collect();
|
||||
let author = "Compare".to_string();
|
||||
let date = super::now_iso();
|
||||
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone() });
|
||||
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone(), move_name: None });
|
||||
let ins = (s.doc.revisions.len() - 1) as u32;
|
||||
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author, date });
|
||||
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author, date, move_name: None });
|
||||
let del = (s.doc.revisions.len() - 1) as u32;
|
||||
let ops = lcs_ops(&old.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), &new.iter().map(|p| p.plain_text()).collect::<Vec<_>>());
|
||||
let mut out: Vec<Paragraph> = Vec::new();
|
||||
|
||||
@@ -197,6 +197,8 @@ pub struct Session {
|
||||
pub path: Option<std::path::PathBuf>,
|
||||
pub dirty: bool,
|
||||
pub clipboard: Option<Fragment>,
|
||||
/// A tracked cut the next paste can turn into a move (see `cmd::moves`).
|
||||
pub pending_move: Option<crate::cmd::moves::PendingMove>,
|
||||
/// Plain text mirror of the clipboard (for the system clipboard).
|
||||
pub clipboard_text: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
@@ -324,6 +326,7 @@ impl Session {
|
||||
path: None,
|
||||
dirty: false,
|
||||
clipboard: None,
|
||||
pending_move: None,
|
||||
clipboard_text: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find: FindState::default(),
|
||||
@@ -600,6 +603,8 @@ impl Session {
|
||||
path: _,
|
||||
dirty,
|
||||
clipboard: _,
|
||||
// Like the clipboard: what the next paste can make a move of.
|
||||
pending_move: _,
|
||||
clipboard_text: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
@@ -774,6 +779,10 @@ impl Session {
|
||||
} else if spec.id.starts_with("caret.") {
|
||||
self.typing_open = false;
|
||||
}
|
||||
// Only the paste right after a tracked cut makes it a move.
|
||||
if (spec.mutates || matches!(id, "edit.undo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") {
|
||||
self.pending_move = None;
|
||||
}
|
||||
let sel_before = self.sel.clone();
|
||||
let run = spec.run;
|
||||
let mutates = spec.mutates;
|
||||
|
||||
@@ -295,6 +295,9 @@ impl MarkupOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour of moved text (both ends of a tracked move).
|
||||
pub const MOVE_COLOR: Rgb = Rgb(0x1A, 0x7F, 0x37);
|
||||
|
||||
/// Colour for revisions by author index.
|
||||
pub fn revision_color(i: u32) -> Rgb {
|
||||
const C: [Rgb; 6] =
|
||||
@@ -646,13 +649,18 @@ fn lines(
|
||||
let del = rc.del.filter(|_| shown);
|
||||
let ins = rc.ins.filter(|_| shown && del.is_none());
|
||||
let author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0));
|
||||
// Moved text: green, double strikethrough where it was, double underline where it went.
|
||||
let moved_to = doc.move_name(ins).is_some();
|
||||
let moved_from = del.is_some() && doc.move_name(del).is_some();
|
||||
let color = match (ins, del) {
|
||||
(_, Some(_)) if moved_from => MOVE_COLOR,
|
||||
(Some(_), _) if moved_to => MOVE_COLOR,
|
||||
(Some(r), _) => m.insert_color.unwrap_or_else(|| author(r)),
|
||||
(None, Some(r)) => m.delete_color.unwrap_or_else(|| author(r)),
|
||||
_ => text_color(&rc.color, rc.shading.or(rc.highlight)),
|
||||
};
|
||||
let ins_mark = ins.map(|_| m.insert_mark);
|
||||
let del_mark = del.map(|_| m.delete_mark);
|
||||
let ins_mark = ins.map(|_| if moved_to { InsertMark::DoubleUnderline } else { m.insert_mark });
|
||||
let del_mark = del.map(|_| if moved_from { DeleteMark::DoubleStrikethrough } else { m.delete_mark });
|
||||
if del_mark == Some(DeleteMark::Caret) {
|
||||
// A caret where the text was, instead of the text.
|
||||
if let Some(cx) = line.cl_left(run_start).map(|c| x + c) {
|
||||
@@ -918,6 +926,7 @@ fn lines(
|
||||
// A tracked (inserted or deleted) paragraph mark is drawn in its author's colour.
|
||||
let m = &opts.revisions;
|
||||
let color = para.filter(|_| opts.markup && m.insertions_deletions).and_then(|p| match (p.mark.ins, p.mark.del) {
|
||||
(ins, del) if doc.move_name(del).is_some() || (del.is_none() && doc.move_name(ins).is_some()) => Some(MOVE_COLOR),
|
||||
(_, Some(r)) => Some(
|
||||
m.delete_color
|
||||
.unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))),
|
||||
|
||||
@@ -694,7 +694,12 @@ fn display_has_glyphs_and_marks() {
|
||||
#[test]
|
||||
fn a_tracked_paragraph_mark_shows_in_its_authors_colour() {
|
||||
let mut d = Document::from_text("Split\nhere");
|
||||
d.revisions.push(wordcraft_doc::Revision { kind: wordcraft_doc::RevisionKind::Insert, author: "Ana".into(), date: String::new() });
|
||||
d.revisions.push(wordcraft_doc::Revision {
|
||||
kind: wordcraft_doc::RevisionKind::Insert,
|
||||
author: "Ana".into(),
|
||||
date: String::new(),
|
||||
move_name: None,
|
||||
});
|
||||
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().mark.ins = Some(0);
|
||||
let l = lay(&d);
|
||||
let marks = |markup: bool| -> Vec<Option<wordcraft_doc::props::Rgb>> {
|
||||
|
||||
@@ -221,8 +221,8 @@ fn hostile_documents_export() {
|
||||
fn tracked_deletions_are_left_out_without_markup() {
|
||||
use wordcraft_doc::{Revision, RevisionKind};
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
let mut p = Paragraph::with_text("Keep ", CharProps::default());
|
||||
let n = p.len();
|
||||
p.insert_text(n, "DELETEDTEXT ", &CharProps { del: Some(0), ..Default::default() }).unwrap();
|
||||
@@ -241,7 +241,7 @@ fn tracked_deletions_are_left_out_without_markup() {
|
||||
fn deleted_heading_text_stays_out_of_bookmarks_and_tags() {
|
||||
use wordcraft_doc::{Revision, RevisionKind};
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
let del = CharProps { del: Some(0), ..Default::default() };
|
||||
// "Intro SECRET Part" with "SECRET " deleted, then a heading deleted as a whole.
|
||||
let mut h = Paragraph::with_text("Intro ", CharProps::default()).styled("Heading1");
|
||||
|
||||
@@ -157,7 +157,9 @@ pub fn markup_width(app: &WordApp) -> f32 {
|
||||
let m = &app.session.prefs.markup;
|
||||
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 }
|
||||
let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&app.session.doc);
|
||||
let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&app.session.doc);
|
||||
if on && (comments || formats || moves) { 216.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A page dimension (points) safe to lay out: finite, at least 1 pt, at most `cap`.
|
||||
@@ -779,6 +781,13 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
||||
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));
|
||||
}
|
||||
// Each end of a tracked move.
|
||||
let moves = if m.insertions_deletions { wordcraft_engine::cmd::review::move_changes(&app.session) } else { Vec::new() };
|
||||
for (pos, author, from) in moves.into_iter().take(500) {
|
||||
let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue };
|
||||
let text = if from { tl!("Moved from") } else { tl!("Moved to") }.to_string();
|
||||
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();
|
||||
let mut clicked: Option<(u32, Pos)> = None;
|
||||
@@ -913,7 +922,7 @@ 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: …".
|
||||
/// A tracked formatting change or move: its author and "Formatted: …" / "Moved from".
|
||||
Format(String, String),
|
||||
}
|
||||
|
||||
|
||||
@@ -1412,6 +1412,8 @@
|
||||
New Group Nuevo grupo
|
||||
Rename… Cambiar nombre…
|
||||
Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido:
|
||||
Moved from Movido desde
|
||||
Moved to Movido a
|
||||
Right-to-Left Table Tabla de derecha a izquierda
|
||||
Right-to-Left Section Sección de derecha a izquierda
|
||||
Table direction: Dirección de la tabla:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1433,6 +1433,8 @@
|
||||
Right (vertical) Paremal (vertikaalne)
|
||||
Ruby text: Ruby-tekst:
|
||||
Size (pt): Suurus (pt):
|
||||
Moved from Teisaldatud kohast
|
||||
Moved to Teisaldatud kohta
|
||||
Align Text Joonda tekst
|
||||
Break Link Katkesta link
|
||||
Click the empty text box the text should continue in (Esc cancels) Klõpsa tühjal tekstiväljal, kus tekst peaks jätkuma (Esc tühistab)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 421 and column 13.
|
@@ -1396,6 +1396,8 @@
|
||||
New Group 新しいグループ
|
||||
Rename… 名前の変更…
|
||||
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
|
||||
Moved from 移動元
|
||||
Moved to 移動先
|
||||
Right-to-Left Table 右から左の表
|
||||
Right-to-Left Section 右から左のセクション
|
||||
Table direction: 表の方向:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 504 and column 13.
|
@@ -1408,6 +1408,8 @@
|
||||
New Group Novo Grupo
|
||||
Rename… Renomear…
|
||||
Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido:
|
||||
Moved from Movido de
|
||||
Moved to Movido para
|
||||
Right-to-Left Table Tabela da direita para a esquerda
|
||||
Right-to-Left Section Seção da direita para a esquerda
|
||||
Table direction: Direção da tabela:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1790,6 +1790,8 @@ preview Title Пример
|
||||
Left Only Только слева
|
||||
Right Only Только справа
|
||||
Largest Only Только по большей стороне
|
||||
Moved from Перемещено из
|
||||
Moved to Перемещено в
|
||||
Right-to-Left Table Таблица справа налево
|
||||
Right-to-Left Section Раздел справа налево
|
||||
Table direction: Направление таблицы:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1406,6 +1406,8 @@
|
||||
New Group Nova grupa
|
||||
Rename… Preimenuj…
|
||||
Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup:
|
||||
Moved from Premešteno odavde
|
||||
Moved to Premešteno ovde
|
||||
Right-to-Left Table Tabela sa desna na levo
|
||||
Right-to-Left Section Odeljak sa desna na levo
|
||||
Table direction: Smer tabele:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1410,6 +1410,8 @@
|
||||
New Group Нова група
|
||||
Rename… Преименуј…
|
||||
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
|
||||
Moved from Премештено одавде
|
||||
Moved to Премештено овде
|
||||
Right-to-Left Table Табела са десна на лево
|
||||
Right-to-Left Section Одељак са десна на лево
|
||||
Table direction: Смер табеле:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -1408,6 +1408,8 @@
|
||||
Left Only Yalnızca Sol
|
||||
Right Only Yalnızca Sağ
|
||||
Largest Only Yalnızca En Geniş
|
||||
Moved from Taşındı (kaynak)
|
||||
Moved to Taşındı (hedef)
|
||||
Right-to-Left Table Sağdan Sola Tablo
|
||||
Right-to-Left Section Sağdan Sola Bölüm
|
||||
Table direction: Tablo yönü:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 430 and column 13.
|
@@ -1408,6 +1408,8 @@
|
||||
New Group Нова група
|
||||
Rename… Перейменувати…
|
||||
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
|
||||
Moved from Переміщено звідси
|
||||
Moved to Переміщено сюди
|
||||
Right-to-Left Table Таблиця справа наліво
|
||||
Right-to-Left Section Розділ справа наліво
|
||||
Table direction: Напрямок таблиці:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -1408,6 +1408,8 @@
|
||||
New Group 新建组
|
||||
Rename… 重命名…
|
||||
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
|
||||
Moved from 移动自
|
||||
Moved to 移动到
|
||||
Right-to-Left Table 从右向左表格
|
||||
Right-to-Left Section 从右向左节
|
||||
Table direction: 表格方向:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1397,6 +1397,8 @@
|
||||
New Group 新增群組
|
||||
Rename… 重新命名…
|
||||
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
|
||||
Moved from 移動自
|
||||
Moved to 移動到
|
||||
Right-to-Left Table 從右至左表格
|
||||
Right-to-Left Section 從右至左節
|
||||
Table direction: 表格方向:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 505 and column 13.
|
@@ -52,7 +52,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `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`, `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 |
|
||||
| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. RTF/ODT/PDF export the result (moved-away text hidden like deletions) |
|
||||
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297) |
|
||||
| Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped |
|
||||
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
|
||||
@@ -85,6 +85,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Move tracking (`w:moveFrom`/`w:moveTo` and their ranges) read and written (#400) |
|
||||
| 2026-10-11 | trivial | DOCX text-box `bodyPr` `vert`/`anchor` and linked text boxes (`wps:linkedTxbx`) read and written (#369) |
|
||||
| 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) |
|
||||
| 2026-10-10 | trivial | Drawing canvas (`wpc:wpc`) read and written (#344); group shapes row brought up to date (#267) |
|
||||
|
||||
+2
-1
@@ -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; 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 | **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) |
|
||||
| 5 | **Content controls** are lost (`w:sdt` unwrapped). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. 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 | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [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**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, 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) |
|
||||
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
|
||||
@@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Move tracking landed (#400); gap #5 narrowed to content controls, 5–8 h |
|
||||
| 2026-10-11 | trivial | #4: note options per section (below text, end of section, restart each section/page) and the Footnote and Endnote dialog (#385) |
|
||||
| 2026-10-11 | trivial | #10: Shape Format › Text: text direction, align text and linked text boxes (#369) |
|
||||
| 2026-10-11 | trivial | #16: right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) laid out mirrored, with `table.rtl` and `layout.sectionRtl` (#362) |
|
||||
|
||||
@@ -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% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |
|
||||
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); 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. 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). |
|
||||
| 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. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Missing: drag-and-drop moves, 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 (Moby list plus our supplement of modern words such as "desktop", #520), 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 (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access 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), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, 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, 125 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, 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) |
|
||||
| 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, 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), move tracking 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) 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, 125 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 | Move tracking (#400): Review row updated; no percentage change |
|
||||
| 2026-10-11 | trivial | Footnotes and endnotes: Footnote and Endnote dialog, options per section (position, format, start, restart each section/page), convert notes (#385); no percentage change |
|
||||
| 2026-10-11 | trivial | Pictures, shapes, text boxes: text direction, Align Text and linked text boxes (Create Link / Break Link), DOCX `bodyPr` `vert`/`anchor` and `wps:linkedTxbx` (#369); no percentage change |
|
||||
| 2026-10-10 | trivial | Drawing Canvas (#344): Insert › Shapes › New Drawing Canvas and Draw › Drawing Canvas live; catalog 402/434 → 404/434 (93.1%) |
|
||||
|
||||
Reference in new issue
Block a user