From 00d991f1b5a3eccde5944c7f76e0a4e1ea2be51c Mon Sep 17 00:00:00 2001
From: Hashem Aldhaheri <158606+aenawi@users.noreply.github.com>
Date: Sat, 5 Sep 2026 15:55:38 +0400
Subject: [PATCH] =?UTF-8?q?feat(ooxml,cli):=20DOCX=20writer=20=E2=80=94=20?=
=?UTF-8?q?M3=203.6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`mirsam repair report.docx fixed.docx` works, and `repair` stops turning a
Word document away as a readable format without a writer. M3 is closed.
`word.rs` is WordprocessingML's repair vocabulary — what `rewrite.rs` is
for DrawingML and `sheet.rs` for SpreadsheetML — and it is the third
vocabulary built on `token.rs` without a Word element name entering it.
Every mutation is a scaffold call with a name in it: `w:pPr/w:bidi`,
`w:pPr/w:jc`, `w:rPr/w:lang@w:bidi`, `w:rPr/w:rFonts@w:cs`,
`w:pPr/w:numPr` and `w:tblPr/w:bidiVisual`, each placed by rank against
the schema sequence its parent is, because `CT_PPrBase` and `CT_RPr` are
`xsd:sequence` and a correct element in the wrong position is a document
Word offers to repair.
The writer needs no inherited direction, and that is Word's doing rather
than a shortcut. 1.2's hardest correction was that lowering `Start` onto
DrawingML's physical `algn` without knowing the paragraph's direction
reproduces the defect being repaired — which is why `rewrite::Inherited`
exists and why `PptxDocument::apply` re-runs its scanner over every part
it touches. `w:jc` is evaluated against the paragraph's own `w:bidi`, so
`Start` is written `start` whichever way the paragraph runs and stays the
start edge if the direction changes afterwards. The whole inheritance
pass is absent from this adapter, not skipped in it.
Two refusals, and both are the format's. A physical edge is not something
a Word paragraph can state, which is the refusal the conformance suite
already records for the reading side; `supports` answers it for the
writing side, so `SetAlignment(Left)` is listed in `repairs.skipped`
rather than lowered onto whichever relative edge happens to land left
today. And a typed bullet becomes a `w:numPr` pointing into the numbering
part, so it can only join a list the document already defines —
`package.rs` replaces the entries a package holds, and a numbering part
that is not there is a part, a content-type override and a relationship,
none of which is an edit to the paragraph the finding named.
`bullet_list` prefers a definition whose first level draws the marker the
author typed, falls back to any bulleted one, and refuses a `w:num` that
overrides its abstract at level zero.
Three smaller decisions, each a defect if taken the other way. ``
is on, so a left-to-right repair writes `w:val="0"` and never an empty
element: creating one to mean off would turn the repair into the defect
it was sent to fix. Filling `@w:cs` removes `@w:cstheme` beside it,
because the theme reference is what Word renders and `@w:cs` the value it
caches for consumers that do not implement themes — writing into the
cache and leaving the reference standing is a repair that reports success
and changes nothing a reader sees. And a `w:t` whose text this repair
changed is marked `xml:space="preserve"` where the result has an edge of
whitespace, because stripping a typed bullet leaves the space behind it
and Word would otherwise collapse a space nobody asked to delete; only
the runs actually rewritten are marked, so the rest of the paragraph
still comes out byte for byte.
`token.rs` gained the ability to step over a caller-named set of
elements, and Word is why. A `w:txbxContent` nests whole paragraphs
inside a run, and each is a unit with its own ordinal, its own text and
its own repairs — offsets the domain computed for the outer paragraph
index the outer paragraph's runs alone. An `mc:Fallback` is stepped over
for the reader's reason: it spells out the same paragraphs as the
`mc:Choice` beside it, and a rewriter that counted both would put
paragraph 2's repair on the fallback's copy of paragraph 1, a report and
a document that had come apart while both still looked right. It also
gained `remove_attribute`, the counterpart `set_attribute` needed once a
repair had to stop a second attribute contradicting the one being
written.
The corpus is where it shows, which is what the recorded refusal was for.
Both `.docx` reports lost their `refused` line and gained a repair
report; `quarterly-review.docx` now carries a `changed_parts` diff naming
exactly the tags above, and its `after` audit is empty — every fixable
finding in a real Word document cleared. `make validate-fixtures` accepts
the repaired copy against the ECMA-376 transitional schemas, and a second
`repair` of that output stages nothing and reproduces it byte for byte.
The CLI case that proved the refusal moved to `.html`, the format that
still gives one, rather than being deleted with the adapter that outgrew
it.
Not run: "Word opens the repaired document without offering to repair
it", the M1 application check in its Word form, cannot run in CI. Schema
validity is the strongest claim a machine can make here and is what the
acceptance rests on. It is not the same claim.
---
AGENTS.md | 41 +-
CHANGELOG.md | 34 +
README.md | 32 +-
crates/mirsam-cli/src/main.rs | 3 +
crates/mirsam-cli/tests/cli.rs | 67 +-
crates/mirsam-cli/tests/golden.rs | 12 +-
crates/mirsam-ooxml/src/docx.rs | 255 +++++-
crates/mirsam-ooxml/src/lib.rs | 13 +-
crates/mirsam-ooxml/src/rewrite.rs | 9 +-
crates/mirsam-ooxml/src/token.rs | 182 +++-
crates/mirsam-ooxml/src/word.rs | 686 +++++++++++++++
crates/mirsam-ooxml/tests/word.rs | 790 ++++++++++++++++++
docs/ARCHITECTURE.md | 15 +-
docs/PLAN.md | 89 +-
docs/ROADMAP.md | 19 +-
.../quarterly-review-correct.expected.json | 48 +-
tests/fixtures/quarterly-review.expected.json | 383 ++++++++-
17 files changed, 2583 insertions(+), 95 deletions(-)
create mode 100644 crates/mirsam-ooxml/src/word.rs
create mode 100644 crates/mirsam-ooxml/tests/word.rs
diff --git a/AGENTS.md b/AGENTS.md
index dc859b6..cc18992 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,7 @@ binary: no Python, no Node, no Office, no runtime of any kind on the target
machine.
`audit` reads `.pptx`, `.docx`, `.xlsx` and `.html`/`.htm`. `repair` writes
-`.pptx` and `.xlsx`: the other two are refused as readable formats without a
+`.pptx`, `.docx` and `.xlsx`: `.html` is refused as a readable format without a
writer, which is a usage error (exit `2`) and not a claim that the document was
not understood.
@@ -176,6 +176,41 @@ through untouched, and mechanically so: `xl/workbook.xml` is not a part a
repair plan can name, and a cell repair edits `@s` or the content of a ``
and nothing beside it.
+### Repairing a Word document, where two things are refused
+
+A `.docx` repair writes `w:pPr/w:bidi`, `w:pPr/w:jc`, `w:rPr/w:lang@w:bidi`,
+`w:rPr/w:rFonts@w:cs`, `w:pPr/w:numPr` and `w:tblPr/w:bidiVisual`, and every
+one of them lands in its schema-sequence position — WordprocessingML's
+property elements are `xsd:sequence`, so a correct element written in the
+wrong place is a document Word offers to repair.
+
+Three things about it are worth knowing before reading a repair report.
+
+- **`w:jc` is written relative, so no direction is resolved to write one.**
+ The PowerPoint writer has to know the direction a paragraph inherits before
+ it can put a `Start` down, because `a:pPr/@algn` names physical edges. Word's
+ does not: `start` is the start edge whichever way the paragraph runs. The
+ same fact is why **a physical edge is refused** — `SetAlignment(Left)` and
+ `SetAlignment(Right)` appear in `repairs.skipped`, never as a repair that
+ failed, because Word has no spelling for "the left of the page whatever the
+ direction". No Word unit ever produces one, so this is a refusal you will not
+ see in practice; it is stated so the writer cannot start inventing one.
+- **`--convert-bullets` needs a list the document already defines.** Word has
+ no per-paragraph bullet: a list is a `w:numPr` pointing into the numbering
+ part. The repair points at a bulleted definition the document already
+ carries, preferring one that draws the marker the author typed, and
+ otherwise the fix is **skipped**. A repair cannot add the numbering part
+ itself — that is a part, a content-type override and a relationship, none of
+ which is an edit to the paragraph the finding named.
+- **`--font` clears `@w:cstheme` on the runs it fills.** `@w:cstheme` is what
+ Word renders and `@w:cs` the value it caches beside it, so writing the
+ typeface into the cache and leaving a theme reference standing would change
+ what the file says and not what a reader sees.
+
+A paragraph inside a text box is a unit of its own and is repaired as one; the
+paragraph anchoring the box is not repaired through it, and an `mc:Fallback`
+is neither counted nor edited — the same two regions the reader steps over.
+
### HTML, where the direction is usually not in the document
An `.html` page states direction in three places and the adapter reads all of
@@ -380,7 +415,9 @@ share a stem: the report is named for it.
A report for a format the tool reads but cannot write holds the refusal
`repair` gave and the exit code it used, in place of a repair report — so the
-day a writer lands, it shows up as a diff on a real document.
+day a writer lands, it shows up as a diff on a real document. That is what the
+two `.docx` reports did when M3's writer landed, and `.html` is what carries
+the refusal now.
**The shaping fixtures are fonts, and they are generated too.** The five
under `crates/mirsam-core/tests/fonts/` are written byte by byte by
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70a1633..95b0278 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,40 @@ Working towards byte-preserving `repair` for PPTX. See
### Added
+- **The DOCX writer.** `mirsam repair report.docx fixed.docx` works, and
+ `repair` stops turning a Word document away as a readable format without a
+ writer. A repair writes `w:pPr/w:bidi`, `w:pPr/w:jc`, `w:rPr/w:lang@w:bidi`,
+ `w:rPr/w:rFonts@w:cs`, `w:pPr/w:numPr` and `w:tblPr/w:bidiVisual`, each
+ placed by rank against the schema sequence its parent is — WordprocessingML's
+ property elements are `xsd:sequence`, and a correct element in the wrong
+ position is a document Word offers to repair. `word.rs` is the third
+ vocabulary built on the same token-rewrite scaffold, and `token.rs` did not
+ have to learn a Word element name to carry it.
+
+ **The writer needs no inherited direction, unlike PowerPoint's.**
+ DrawingML's `algn` names physical edges, so lowering a `Start` there without
+ knowing the paragraph's direction reproduces the defect being repaired.
+ Word's `w:jc` is evaluated against the paragraph's own `w:bidi`, so `start`
+ is the start edge whichever way the paragraph runs — the inheritance pass is
+ absent from this adapter rather than skipped in it.
+
+ **Two repairs are refused, and both refusals are the format's.** Word has no
+ spelling for a *physical* edge, which is the refusal the conformance suite
+ already records for the reading side, so `SetAlignment(Left)` appears in
+ `repairs.skipped` rather than being lowered onto whichever relative edge
+ lands left today. And a typed bullet becomes a `w:numPr` pointing into the
+ numbering part, so `--convert-bullets` can only join a list the document
+ already defines — preferring one that draws the marker the author typed —
+ and is skipped otherwise. Filling `@w:cs` also clears `@w:cstheme` beside
+ it, because the theme reference is what Word renders and leaving it standing
+ would be a repair that changes what the file says and not what a reader
+ sees. See [`docs/PLAN.md`](docs/PLAN.md) §3.6.
+
+ The two corpus Word documents recorded the refusal this replaces, so the
+ change arrives as a repair report and a part-level diff on
+ `quarterly-review.docx` — whose `after` audit is now empty — rather than as
+ a test somebody remembered to update.
+
- **The XLSX adapter, and the second format `repair` can write.** `mirsam
audit book.xlsx` reads a workbook and `mirsam repair book.xlsx fixed.xlsx`
writes one. A cell is a paragraph and the worksheet around it is the
diff --git a/README.md b/README.md
index 1b86298..c0ab2f8 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,8 @@
finds right-to-left, bidirectional and typography defects in PowerPoint, Word,
Excel and HTML files, *proves* each one by resolving what the text will
actually look like when rendered, and repairs them without touching a byte it
-was not asked to. PowerPoint and Excel are repaired; Word and HTML are
-audit-only so far, and the PDF adapter is scheduled; see
-[Roadmap](docs/ROADMAP.md).
+was not asked to. PowerPoint, Word and Excel are repaired; HTML is audit-only
+so far, and the PDF adapter is scheduled; see [Roadmap](docs/ROADMAP.md).
> **mirsam** (مَرْسَم) — an atelier; the room where things are properly drawn.
> From the root ر-س-م: to draw, to inscribe, to render.
@@ -154,12 +153,12 @@ so an agent can act on it without opening PowerPoint. See [`AGENTS.md`](AGENTS.m
**v0.1 “Steppe Eagle” — audit only, PPTX only.** Byte-preserving `repair` for
PPTX has landed on `main` and ships as v0.2. Three formats have landed since.
-DOCX and HTML are **readers**: `mirsam audit report.docx` works, and `mirsam
-audit page.html` reads `dir`, `lang` and the part of CSS that decides direction
-— including a stylesheet the page links by a relative path. `repair` refuses
-both and says why, because neither writer is built yet. A page linking a
-stylesheet on a server is audited without it and the report names what it did
-not read, because this tool makes no network calls.
+
+HTML is a **reader**: `mirsam audit page.html` reads `dir`, `lang` and the part
+of CSS that decides direction — including a stylesheet the page links by a
+relative path. `repair` refuses it and says why, because its writer is not
+built. A page linking a stylesheet on a server is audited without it and the
+report names what it did not read, because this tool makes no network calls.
XLSX is the second format with a **writer**: `mirsam repair book.xlsx
fixed.xlsx` sets a sheet's `rightToLeft` and a cell's reading order and
@@ -167,10 +166,17 @@ alignment, and leaves every formula and defined name exactly as it found them
— a workbook's formatting is shared between its cells, so a repair appends a
format record and repoints the one cell rather than editing what forty cells
are using. A formula's cached result is not judged and is named in the report,
-for the reason a stylesheet on a server is. The PDF adapter is specified and
-scheduled; see [`docs/ROADMAP.md`](docs/ROADMAP.md). The tool reports what it
-can actually verify and nothing more — a discipline inherited from its prior
-art.
+for the reason a stylesheet on a server is.
+
+DOCX is the third: `mirsam repair report.docx fixed.docx` writes `w:bidi`,
+`w:jc`, the complex-script language and font slots, a table's `w:bidiVisual`
+and a real list in place of a typed bullet — each in the schema position Word
+requires. Two repairs are refused rather than approximated, and both refusals
+are the format's: Word has no way to state a *physical* edge, and a typed
+bullet can only join a list the document already defines. The PDF adapter is
+specified and scheduled; see [`docs/ROADMAP.md`](docs/ROADMAP.md). The tool
+reports what it can actually verify and nothing more — a discipline inherited
+from its prior art.
## Design
diff --git a/crates/mirsam-cli/src/main.rs b/crates/mirsam-cli/src/main.rs
index 864ada2..68fe069 100644
--- a/crates/mirsam-cli/src/main.rs
+++ b/crates/mirsam-cli/src/main.rs
@@ -302,6 +302,9 @@ fn open_for_repair(path: &Path) -> Result> {
"pptx" => Ok(Box::new(
PptxDocument::open(path).with_context(|| format!("opening {}", path.display()))?,
)),
+ "docx" => Ok(Box::new(
+ DocxDocument::open(path).with_context(|| format!("opening {}", path.display()))?,
+ )),
"xlsx" => Ok(Box::new(
XlsxDocument::open(path).with_context(|| format!("opening {}", path.display()))?,
)),
diff --git a/crates/mirsam-cli/tests/cli.rs b/crates/mirsam-cli/tests/cli.rs
index de29786..79183a3 100644
--- a/crates/mirsam-cli/tests/cli.rs
+++ b/crates/mirsam-cli/tests/cli.rs
@@ -766,21 +766,76 @@ fn a_correctly_marked_word_document_exits_zero() {
#[test]
fn repairing_a_readable_but_unwritable_format_says_so_rather_than_denying_it() {
- // `docx` is not an unknown extension — `audit` reads it. Reporting it as
+ // `html` is not an unknown extension — `audit` reads it. Reporting it as
// one would be the tool contradicting itself between two commands, so the
// refusal names the real reason and stays a usage error.
- let scratch = Scratch::new("docx-repair");
- let input = docx(&scratch, "in.docx", "");
- let out = run(&["repair", &input, &scratch.path("out.docx")]);
+ //
+ // This case was written against `.docx`, which is the format that used to
+ // answer it. Word gained a writer, so the claim moved to the format that
+ // still has none rather than being deleted with the adapter that outgrew
+ // it: what is under test is the refusal, not which format gives it.
+ let scratch = Scratch::new("html-repair");
+ let input = scratch.file(
+ "page.html",
+ "
ارتفع الأداء بنسبة 25%
".as_bytes(),
+ );
+ let out = run(&["repair", &input, &scratch.path("out.html")]);
- assert_eq!(out.code, exit::USAGE);
+ assert_eq!(out.code, exit::USAGE, "stderr:\n{}", out.stderr);
assert!(
out.stderr.contains("audited") && out.stderr.contains("not yet repaired"),
"stderr:\n{}",
out.stderr
);
// And nothing was written.
- assert!(!Path::new(&scratch.path("out.docx")).exists());
+ assert!(!Path::new(&scratch.path("out.html")).exists());
+}
+
+#[test]
+fn a_word_document_is_repaired_and_the_repaired_copy_audits_clean() {
+ // The other side of the case above: `repair` no longer turns a `.docx`
+ // away, and what it writes is a document the same binary reads back with
+ // the finding gone.
+ let scratch = Scratch::new("docx-repaired");
+ let input = docx(
+ &scratch,
+ "broken.docx",
+ r#""#,
+ );
+ let output = scratch.path("fixed.docx");
+
+ let out = run(&["repair", &input, &output, "--format", "json"]);
+ assert_eq!(out.code, exit::OK, "stderr:\n{}", out.stderr);
+
+ let json = parse_json(&out);
+ assert_eq!(json["format"], "docx");
+ assert!(
+ !json["repairs"]["applied"].as_array().unwrap().is_empty(),
+ "{json:#?}"
+ );
+ assert!(
+ json["repairs"]["skipped"].as_array().unwrap().is_empty(),
+ "nothing in this document is beyond WordprocessingML:\n{json:#?}"
+ );
+ assert!(
+ rule_ids(&json["before"]["diagnostics"]).contains(&"direction-mismatch".to_string()),
+ "{json:#?}"
+ );
+ assert!(
+ !rule_ids(&json["after"]["diagnostics"]).contains(&"direction-mismatch".to_string()),
+ "{json:#?}"
+ );
+
+ // The input is never modified, and the direction landed on the paragraph.
+ let repaired = std::fs::read(&output).unwrap();
+ let mut archive = zip::ZipArchive::new(std::io::Cursor::new(repaired)).unwrap();
+ let mut document = String::new();
+ std::io::Read::read_to_string(
+ &mut archive.by_name("word/document.xml").unwrap(),
+ &mut document,
+ )
+ .unwrap();
+ assert!(document.contains(r#""#), "{document}");
}
#[test]
diff --git a/crates/mirsam-cli/tests/golden.rs b/crates/mirsam-cli/tests/golden.rs
index aaed77b..bd13b28 100644
--- a/crates/mirsam-cli/tests/golden.rs
+++ b/crates/mirsam-cli/tests/golden.rs
@@ -13,12 +13,16 @@
//!
//! ## A format the tool reads but cannot write
//!
-//! `repair` refuses a `.docx`, and refuses it as a *readable format without a
+//! `repair` refuses an `.html`, and refuses it as a *readable format without a
//! writer* rather than as an unknown extension. That refusal is recorded here
//! rather than worked around: the report holds the message and the exit code
-//! it produced, so the day the Word writer lands, the corpus shows the change
-//! as a diff on a real document instead of as a test somebody remembered to
-//! update.
+//! it produced, so the day the writer lands, the corpus shows the change as a
+//! diff on a real document instead of as a test somebody remembered to update.
+//!
+//! It did exactly that for Word. The two `.docx` reports held that refusal
+//! until M3's writer landed, and the change arrived as a repair report and a
+//! part-level diff on `quarterly-review.docx` — which is the review this
+//! paragraph exists to force.
//!
//! A diff is a change in behaviour. When it is intended, regenerate the
//! reports and commit them with the change that caused them, so the diff is
diff --git a/crates/mirsam-ooxml/src/docx.rs b/crates/mirsam-ooxml/src/docx.rs
index e7c1aa8..d432301 100644
--- a/crates/mirsam-ooxml/src/docx.rs
+++ b/crates/mirsam-ooxml/src/docx.rs
@@ -71,29 +71,41 @@
//! caller holding one part and no package — an unstated property stays
//! `Unset` rather than being guessed at.
//!
-//! ## Reading only, for now
+//! ## Writing
//!
-//! [`DocumentWriter`] is deliberately not implemented. `DocumentReader` and
-//! `DocumentWriter` are separate ports precisely so an adapter can arrive one
-//! half at a time, and claiming a repair this crate cannot yet express in
-//! WordprocessingML would be the kind of inferred capability `AGENTS.md`
-//! forbids.
+//! [`DocumentWriter`] lands the repairs, and [`crate::word`] is where every
+//! element name they are written with lives — this module groups them by the
+//! part and the paragraph a unit id names and hands the rest over.
//!
+//! Two of the shape it takes are worth reading before the code. It needs no
+//! map of inherited directions, unlike the PowerPoint adapter: `w:jc` is
+//! relative to the paragraph's own direction, so there is nothing to lower a
+//! `Start` against. And a typed bullet is converted by pointing the paragraph
+//! at a list the document already defines, so [`supports`] answers *no* for
+//! `ConvertLiteralBullet` on a document that defines none — a `w:numPr` naming
+//! a list that does not exist is a document Word offers to repair.
+//!
+//! [`supports`]: mirsam_core::ports::DocumentWriter::supports
//! [`DocumentWriter`]: mirsam_core::ports::DocumentWriter
//! [ECMA-376]: https://ecma-international.org/publications-and-standards/standards/ecma-376/
//! [MS-OE376]: https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/26ecf09a-0f0b-4574-9907-ebd1ddf3015f
use crate::inherit::{ThemeFont, ThemeScript};
-use crate::package::Package;
+use crate::package::{Edits, Package};
+use crate::rels::RelationshipGraph;
use crate::style::{StyleSheet, theme_reference};
use crate::token::is_true;
+use crate::word;
use mirsam_core::error::{Error, Result};
-use mirsam_core::ports::DocumentReader;
+use mirsam_core::ports::{DocumentReader, DocumentWriter};
use mirsam_core::text::{
Alignment, Bullet, Direction, Location, Properties, Resolved, TextUnit, UnitKind,
};
+use mirsam_core::{Fix, Repair, UnitId};
use quick_xml::events::{BytesStart, Event};
use quick_xml::{Reader, XmlVersion};
+use std::cell::RefCell;
+use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Alignment values WordprocessingML understands, as *Word* reads them.
@@ -157,6 +169,34 @@ fn table_id(part: &str, index: usize) -> String {
format!("{part}#tbl{index}")
}
+/// What a unit id this adapter issued points at.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Target {
+ Paragraph(usize),
+ Table(usize),
+}
+
+/// Recover the part and target from an id this adapter issued.
+///
+/// `#` cannot occur in an OPC part name, so the last `#` is unambiguous, and
+/// `tbl` is tried before `p` because `p` is one letter. The same shapes the
+/// PowerPoint adapter issues parse the same way here; an id from any other
+/// adapter is refused rather than half-understood, because a repair that
+/// landed on "whatever paragraph 3 of this part is" would be a repair the
+/// report never asked for.
+fn parse_unit_id(id: &UnitId) -> Option<(&str, Target)> {
+ let (part, rest) = id.0.rsplit_once('#')?;
+ let ordinal = |digits: &str| digits.parse::().ok().filter(|n| *n > 0);
+
+ let target = if let Some(n) = rest.strip_prefix("tbl") {
+ Target::Table(ordinal(n)?)
+ } else {
+ Target::Paragraph(ordinal(rest.strip_prefix('p')?)?)
+ };
+
+ (!part.is_empty()).then_some((part, target))
+}
+
/// Accumulates a table while the rows, cells and paragraphs inside it are
/// being parsed.
///
@@ -588,15 +628,27 @@ impl PartScan {
}
}
-/// A Word package opened for auditing.
+/// A Word package opened for auditing and repair.
pub struct DocxDocument {
package: Package,
+ /// Parts rewritten by [`DocumentWriter::apply`], awaiting
+ /// [`DocumentWriter::write`]. Everything else is copied raw.
+ edits: Edits,
+ /// The list each typed marker converts to, answered once per marker.
+ ///
+ /// [`DocumentWriter::supports`] takes `&self` and is asked the same
+ /// question for every literal bullet in the document; reading and parsing
+ /// `word/numbering.xml` once per finding would be a cost that grows with
+ /// the defect count for an answer that cannot change.
+ bullets: RefCell>>,
}
impl DocxDocument {
pub fn open(path: impl Into) -> Result {
Ok(Self {
package: Package::open(path)?,
+ edits: Edits::new(),
+ bullets: RefCell::new(BTreeMap::new()),
})
}
@@ -634,6 +686,52 @@ impl DocxDocument {
StyleSheet::read(&self.package)
}
+ /// One part's XML, taking a staged edit over the package's own bytes so
+ /// that repairs applied in two rounds compose rather than the second round
+ /// discarding the first.
+ fn part_text(&self, part: &str) -> Result {
+ match self.edits.get(part) {
+ Some(bytes) => {
+ String::from_utf8(bytes.clone()).map_err(|e| Error::Format(format!("{part}: {e}")))
+ }
+ None => self.package.read_text(part),
+ }
+ }
+
+ /// The `w:numId` a paragraph joins when a typed `marker` is converted, or
+ /// `None` when this document defines no bulleted list.
+ ///
+ /// The numbering part is reached by the relationship pointing at it rather
+ /// than by its conventional path, for the reason [`crate::style`] gives
+ /// about `styles.xml`: a package is free to store it anywhere, and a
+ /// writer that hard-codes `word/numbering.xml` refuses a repair it could
+ /// have made on a document that stores it elsewhere.
+ ///
+ /// An unreadable or malformed numbering part answers `None`. That is the
+ /// same answer as "there is no list here", and it is the right one: the
+ /// repair is reported as *not made* rather than attempted against a part
+ /// this adapter could not understand.
+ fn bullet_list(&self, marker: char) -> Option {
+ if let Some(known) = self.bullets.borrow().get(&marker) {
+ return known.clone();
+ }
+ let found = self.numbering_part().and_then(|part| {
+ let xml = self.package.read_text(&part).ok()?;
+ word::bullet_list(&part, &xml, marker).ok().flatten()
+ });
+ self.bullets.borrow_mut().insert(marker, found.clone());
+ found
+ }
+
+ /// The part this document defines its lists in.
+ fn numbering_part(&self) -> Option {
+ let graph = RelationshipGraph::read(&self.package).ok()?;
+ let document = graph.office_document()?.to_string();
+ graph
+ .first_part_of_kind(&document, "numbering")
+ .map(str::to_string)
+ }
+
/// Parse one `word/**/*.xml` part into text units.
///
/// Direction, alignment, the font slots and the language are recorded as
@@ -724,13 +822,108 @@ impl DocumentReader for DocxDocument {
let styles = self.styles()?;
let mut units = Vec::new();
for part in self.text_parts()? {
- let xml = self.package.read_text(&part)?;
+ let xml = self.part_text(&part)?;
units.extend(Self::scan_part(&part, &xml, Some(&styles))?);
}
Ok(units)
}
}
+impl DocumentWriter for DocxDocument {
+ /// Whether WordprocessingML can express `fix`.
+ ///
+ /// Two refusals, and both are the format's rather than this adapter's.
+ ///
+ /// A **physical edge** is not something a Word paragraph can state:
+ /// `w:jc`'s values are evaluated against the paragraph's own `w:bidi`, so
+ /// there is no value meaning "the left of the page whatever the direction"
+ /// to write [`Alignment::Left`] as. This is the writing half of the
+ /// refusal the conformance suite states for reading, and it is why no
+ /// Word unit ever comes back carrying one.
+ ///
+ /// A **typed bullet** is converted by pointing the paragraph at a list,
+ /// and the list has to be one this document already defines. A repair
+ /// cannot supply it: [`crate::package`] replaces the entries a package
+ /// holds, and a numbering part that is not there needs a part, a
+ /// content-type override and a relationship, none of which is an edit to
+ /// the paragraph the finding named.
+ fn supports(&self, fix: &Fix) -> bool {
+ match fix {
+ Fix::SetAlignment(Alignment::Left | Alignment::Right) => false,
+ Fix::ConvertLiteralBullet { marker } => self.bullet_list(*marker).is_some(),
+ _ => true,
+ }
+ }
+
+ /// Stage repairs against the parts they name.
+ ///
+ /// Repairs are grouped by part and then by paragraph, so a part is read
+ /// and rewritten once however many paragraphs it carries. Nothing is
+ /// staged unless every part succeeds: a failure half-way through must not
+ /// leave a document that is partly repaired and reports otherwise.
+ ///
+ /// There is no inheritance pass here, and its absence is Word's doing.
+ /// The PowerPoint adapter has to re-run its scanner over each part to
+ /// learn the direction every paragraph takes from its container, because
+ /// `a:pPr/@algn` names physical edges and a `Start` cannot be written
+ /// without one. `w:jc` is already relative to the paragraph's own
+ /// direction, so the rewriter needs nothing the paragraph does not carry.
+ fn apply(&mut self, repairs: &[Repair]) -> Result {
+ let mut by_part: BTreeMap = BTreeMap::new();
+ let mut bullets = word::Bullets::new();
+
+ for repair in repairs {
+ let Some((part, target)) = parse_unit_id(&repair.unit) else {
+ return Err(Error::Format(format!(
+ "{}: not a unit this adapter produced",
+ repair.unit
+ )));
+ };
+ // Resolved here rather than in the rewriter, which holds no
+ // package and so cannot see the numbering part. `supports` has
+ // already answered this question for every repair the caller
+ // staged; a `Fix` that reached `apply` anyway is refused rather
+ // than written as a `w:numPr` naming nothing.
+ if let Fix::ConvertLiteralBullet { marker } = &repair.fix {
+ let Some(num_id) = self.bullet_list(*marker) else {
+ return Err(Error::Format(format!(
+ "{part}: cannot {}; this document defines no bulleted list \
+ for a paragraph to join",
+ repair.fix
+ )));
+ };
+ bullets.insert(*marker, num_id);
+ }
+
+ let plan = by_part.entry(part.to_string()).or_default();
+ match target {
+ Target::Paragraph(index) => plan.paragraphs.entry(index).or_default(),
+ Target::Table(index) => plan.tables.entry(index).or_default(),
+ }
+ .push(repair.fix.clone());
+ }
+
+ let mut staged = Edits::new();
+ let mut applied = 0usize;
+ for (part, plan) in &by_part {
+ // A part edited by an earlier call is edited again from its staged
+ // bytes, so repairs applied in two rounds compose.
+ let xml = self.part_text(part)?;
+ let rewritten = word::apply_plan(part, &xml, plan, &bullets)?;
+ applied += plan.len();
+ staged.insert(part.clone(), rewritten.into_bytes());
+ }
+
+ self.edits.extend(staged);
+ Ok(applied)
+ }
+
+ fn write(&mut self, dest: &Path) -> Result<()> {
+ self.package.rewrite(dest, &self.edits)?;
+ Ok(())
+ }
+}
+
/// Parse an in-memory part into every unit this adapter produces for it.
///
/// Exposed for tests and for callers that already hold the XML. There is no
@@ -744,3 +937,45 @@ pub fn scan_xml(part: &str, xml: &str) -> Result> {
pub fn scan_xml_with(part: &str, xml: &str, styles: Option<&StyleSheet>) -> Result> {
DocxDocument::scan_part(part, xml, styles)
}
+
+#[cfg(test)]
+mod unit_id_tests {
+ use super::*;
+
+ #[test]
+ fn a_unit_id_round_trips_through_its_own_parser() {
+ let part = "word/document.xml";
+ assert_eq!(
+ parse_unit_id(&UnitId(unit_id(part, 3))),
+ Some((part, Target::Paragraph(3)))
+ );
+ assert_eq!(
+ parse_unit_id(&UnitId(table_id(part, 2))),
+ Some((part, Target::Table(2)))
+ );
+ }
+
+ #[test]
+ fn an_id_this_adapter_did_not_issue_is_rejected() {
+ for foreign in [
+ "",
+ "document",
+ "#p1",
+ "word/document.xml#p0",
+ "x#px",
+ "x#p-1",
+ "x#tbl0",
+ "x#tblx",
+ "x#t1",
+ // The containers the PowerPoint adapter issues and this one does
+ // not: a Word paragraph is not laid out in columns, and a chart in
+ // a Word document is a part the DrawingML reader answers for.
+ "x#cols1",
+ "x#catax1",
+ "x#legend1",
+ "x#dlbls1",
+ ] {
+ assert_eq!(parse_unit_id(&UnitId(foreign.into())), None, "{foreign:?}");
+ }
+ }
+}
diff --git a/crates/mirsam-ooxml/src/lib.rs b/crates/mirsam-ooxml/src/lib.rs
index 1caf808..d2af42b 100644
--- a/crates/mirsam-ooxml/src/lib.rs
+++ b/crates/mirsam-ooxml/src/lib.rs
@@ -23,12 +23,12 @@
//! inside a part. Everything else names elements, and an element name belongs
//! to exactly one format: [`rewrite`] is DrawingML's repair vocabulary,
//! [`pptx`] its reader, [`inherit`] the layout/master chain it resolves,
-//! [`chart`] the chart parts a deck references, and [`docx`] and [`style`]
-//! WordprocessingML's reader and its own style chain — which share
-//! [`package`] and [`token`] with the rest and name not one DrawingML
-//! element. The theme is the exception both formats really do share: a
-//! `a:fontScheme` is DrawingML wherever it is stored, so [`inherit`] reads
-//! Word's theme part unchanged.
+//! [`chart`] the chart parts a deck references, and [`docx`], [`style`] and
+//! [`word`] WordprocessingML's reader, its own style chain and its repair
+//! vocabulary — which share [`package`] and [`token`] with the rest and name
+//! not one DrawingML element. The theme is the exception both formats really
+//! do share: a `a:fontScheme` is DrawingML wherever it is stored, so
+//! [`inherit`] reads Word's theme part unchanged.
//!
//! SpreadsheetML is the third vocabulary, and it reuses those same two shared
//! modules and nothing else: [`xlsx`] is Excel's reader and writer,
@@ -54,6 +54,7 @@ pub mod rewrite;
pub mod sheet;
pub mod style;
pub mod token;
+pub mod word;
pub mod workbook;
pub mod xlsx;
diff --git a/crates/mirsam-ooxml/src/rewrite.rs b/crates/mirsam-ooxml/src/rewrite.rs
index 79f7b5b..cc6144b 100644
--- a/crates/mirsam-ooxml/src/rewrite.rs
+++ b/crates/mirsam-ooxml/src/rewrite.rs
@@ -11,9 +11,12 @@
//! of its own: every mutation below is a scaffold call with a DrawingML name
//! in it.
//!
-//! That split is what makes a second format cheap. A WordprocessingML repair
-//! is the same scaffold with `w:p`, `w:pPr/w:bidi` and `w:jc` in place of the
-//! names here; nothing in [`crate::token`] has to learn about it.
+//! That split is what makes a second format cheap, and [`crate::word`] is the
+//! claim collected: the same scaffold with `w:p`, `w:pPr/w:bidi` and `w:jc` in
+//! place of the names here, and not a line of [`crate::token`] moved to carry
+//! it. It came out simpler than this module in one respect — see
+//! [`Inherited`], whose Word counterpart does not exist because `w:jc` is
+//! already relative to the paragraph's own direction.
use crate::chart::ChartText;
use crate::token::is_true;
diff --git a/crates/mirsam-ooxml/src/token.rs b/crates/mirsam-ooxml/src/token.rs
index 3936415..3d80944 100644
--- a/crates/mirsam-ooxml/src/token.rs
+++ b/crates/mirsam-ooxml/src/token.rs
@@ -6,7 +6,8 @@
//! splices attribute values in their raw bytes, inserts children at a position
//! a caller-supplied schema sequence decides, and reads and rewrites run text.
//! Which elements and attributes those are is the *vocabulary*, and the
-//! vocabulary lives with its format — [`crate::rewrite`] holds DrawingML's.
+//! vocabulary lives with its format — [`crate::rewrite`] holds DrawingML's,
+//! [`crate::word`] WordprocessingML's and [`crate::sheet`] SpreadsheetML's.
//!
//! Nothing here names an element. `a:t` and `w:t` are both "the element whose
//! content is run text", and a caller says which it means; `a:pPr` and `w:pPr`
@@ -14,6 +15,14 @@
//! sequence. That is the whole of the separation, and it is why a second
//! adapter reuses this file rather than copying it.
//!
+//! *Which* elements a scan should step over is a vocabulary question too, so
+//! the `_outside` functions take the names from the caller. DrawingML never
+//! needs them — an `a:p` cannot contain another. WordprocessingML does:
+//! [`crate::word`] passes `w:txbxContent`, because a text box nests whole
+//! paragraphs that are units in their own right, and `mc:Fallback`, because
+//! [`crate::docx`] does not read one and a rewriter that counted it would
+//! number the paragraphs differently from the report it is applying.
+//!
//! Two rules follow from the byte-preservation guarantee, and they shape all
//! the code here.
//!
@@ -54,10 +63,14 @@ pub fn is_true(value: &str) -> bool {
// ------------------------------------------------------------ raw attributes
-/// One attribute's name and the byte range of its value inside a tag's content.
+/// One attribute's name and the byte ranges it occupies inside a tag's content.
struct RawAttribute {
name: String,
+ /// The value alone, inside its quotes: what [`set_attribute`] splices.
value: std::ops::Range,
+ /// The whole attribute, from the first byte of its name to its closing
+ /// quote: what [`remove_attribute`] cuts out.
+ span: std::ops::Range,
}
/// Scan a start tag's raw content into attribute names and value ranges.
@@ -112,11 +125,15 @@ fn scan_attributes(content: &str) -> Vec {
while i < content.len() && content[i] != quote {
i += 1;
}
+ let value = value_start..i;
+ i += 1; // past the closing quote
out.push(RawAttribute {
name,
- value: value_start..i,
+ value,
+ // Clamped: an unterminated value leaves `i` past the end, and a
+ // malformed tag must not make a caller's slice panic.
+ span: name_start..i.min(content.len()),
});
- i += 1; // past the closing quote
}
out
@@ -167,6 +184,36 @@ pub fn set_attribute(tag: &BytesStart, name: &str, value: &str) -> BytesStart<'s
BytesStart::from_content(replaced, tag.name().0.len())
}
+/// Delete an attribute and the whitespace that separated it, leaving every
+/// other byte of the tag untouched. A tag that does not carry it is returned
+/// as it was.
+///
+/// The counterpart to [`set_attribute`], and needed for the same reason: some
+/// repairs are only true if a second attribute stops contradicting the one
+/// being written. WordprocessingML's `w:rFonts` is the case in point — Word
+/// renders `@w:cstheme` and keeps `@w:cs` beside it as a cached answer, so
+/// writing the typeface into `@w:cs` and leaving a theme reference standing
+/// would change what the file *says* and not what a reader *sees*.
+pub fn remove_attribute(tag: &BytesStart, name: &str) -> BytesStart<'static> {
+ let content: &str = tag;
+ let Some(found) = scan_attributes(content)
+ .into_iter()
+ .find(|a| a.name == name)
+ else {
+ return tag.clone().into_owned();
+ };
+
+ // Take the whitespace before the attribute with it, so removing the only
+ // attribute leaves `` rather than ``.
+ let start = content[..found.span.start]
+ .bytes()
+ .rposition(|b| !b.is_ascii_whitespace())
+ .map_or(0, |i| i + 1);
+ let kept = format!("{}{}", &content[..start], &content[found.span.end..]);
+
+ BytesStart::from_content(kept, tag.name().0.len())
+}
+
// --------------------------------------------------------------- event stream
/// Read a part into an owned event stream.
@@ -269,13 +316,52 @@ pub fn element_ranges(
events: &[Event<'static>],
name: &str,
) -> Vec<(usize, std::ops::Range)> {
+ element_ranges_outside(events, name, &[])
+}
+
+/// [`element_ranges`], skipping the whole of any element named in `excluded`.
+///
+/// A rewriter must number the elements it edits exactly as the scanner that
+/// produced the report numbered them, and a scanner that does not read some
+/// region of the part does not count what is in it. DrawingML has never
+/// needed this; WordprocessingML does, because its reader steps over an
+/// `mc:Fallback` — the fallback and the `mc:Choice` beside it spell out the
+/// same paragraphs, and counting both would put a repair on the wrong one.
+///
+/// The container's own start tag, at index 0 of a slice, is not a candidate
+/// for exclusion: a caller passing a paragraph and excluding paragraphs means
+/// the ones nested inside it.
+pub fn element_ranges_outside(
+ events: &[Event<'static>],
+ name: &str,
+ excluded: &[&str],
+) -> Vec<(usize, std::ops::Range)> {
+ element_starts_outside(events, name, excluded)
+ .into_iter()
+ .enumerate()
+ .map(|(n, i)| (n + 1, element_range(events, i)))
+ .collect()
+}
+
+/// The index of every element named `name`, in document order, skipping the
+/// whole of any element named in `excluded`. See [`element_ranges_outside`].
+pub fn element_starts_outside(
+ events: &[Event<'static>],
+ name: &str,
+ excluded: &[&str],
+) -> Vec {
let mut out = Vec::new();
- let mut index = 0usize;
- for i in 0..events.len() {
- if name_of(&events[i]).as_deref() == Some(name) {
- index += 1;
- out.push((index, element_range(events, i)));
+ let mut i = 0usize;
+ while i < events.len() {
+ match name_of(&events[i]).as_deref() {
+ Some(found) if i > 0 && excluded.contains(&found) => {
+ i = element_range(events, i).end;
+ continue;
+ }
+ Some(found) if found == name => out.push(i),
+ _ => {}
}
+ i += 1;
}
out
}
@@ -406,20 +492,31 @@ pub fn text_content_ranges(
events: &[Event<'static>],
text_element: &str,
) -> Vec> {
- let mut out = Vec::new();
- let mut start = None;
- for (i, event) in events.iter().enumerate() {
- match event {
- Event::Start(e) if e.name().0 == text_element => start = Some(i + 1),
- Event::End(e) if e.name().0 == text_element => {
- if let Some(from) = start.take() {
- out.push(from..i);
- }
- }
- _ => {}
- }
- }
- out
+ text_content_ranges_outside(events, text_element, &[])
+}
+
+/// [`text_content_ranges`], skipping the whole of any element named in
+/// `excluded` — a caller's way of saying which runs are *this* unit's own.
+///
+/// DrawingML has no use for it, because an `a:p` cannot contain another one. A
+/// WordprocessingML paragraph can: a text box is a `w:txbxContent` inside a
+/// run, and the paragraphs in it are units in their own right, with their own
+/// text and their own repairs. An offset the domain computed for the outer
+/// paragraph indexes the outer paragraph's runs alone, so a repair that
+/// reached the inner ones would delete from a string nobody scanned.
+pub fn text_content_ranges_outside(
+ events: &[Event<'static>],
+ text_element: &str,
+ excluded: &[&str],
+) -> Vec> {
+ element_starts_outside(events, text_element, excluded)
+ .into_iter()
+ // A self-closing `` holds no content and so has no range; the
+ // element range of one is a single event, and `end - 1` would be the
+ // start tag itself.
+ .filter(|&i| matches!(events[i], Event::Start(_)))
+ .map(|i| i + 1..element_range(events, i).end - 1)
+ .collect()
}
/// Resolve a run of content events to logical-order text.
@@ -486,9 +583,20 @@ pub fn replace_content(
/// untouched — character references and all.
pub fn map_runs(events: &mut Vec>, text_element: &str, f: impl Fn(&str) -> String) {
let ranges = text_content_ranges(events, text_element);
- let before = read_runs(events, &ranges);
+ map_runs_in(events, &ranges, f);
+}
+
+/// [`map_runs`], over a caller-chosen set of content ranges — the ones
+/// [`text_content_ranges_outside`] answered with, where some of the element's
+/// runs belong to a unit inside it rather than to the unit being repaired.
+pub fn map_runs_in(
+ events: &mut Vec>,
+ ranges: &[std::ops::Range],
+ f: impl Fn(&str) -> String,
+) {
+ let before = read_runs(events, ranges);
let after: Vec = before.iter().map(|text| f(text)).collect();
- replace_content(events, &ranges, &before, &after);
+ replace_content(events, ranges, &before, &after);
}
/// Remove the characters at the given offsets into the element's text.
@@ -498,7 +606,17 @@ pub fn map_runs(events: &mut Vec>, text_element: &str, f: impl Fn
/// to front so that removing one does not move the next.
pub fn remove_at_offsets(events: &mut Vec>, text_element: &str, offsets: &[usize]) {
let ranges = text_content_ranges(events, text_element);
- let before = read_runs(events, &ranges);
+ remove_at_offsets_in(events, &ranges, offsets);
+}
+
+/// [`remove_at_offsets`], over a caller-chosen set of content ranges. See
+/// [`map_runs_in`] for when a caller has to choose them.
+pub fn remove_at_offsets_in(
+ events: &mut Vec>,
+ ranges: &[std::ops::Range],
+ offsets: &[usize],
+) {
+ let before = read_runs(events, ranges);
let mut runs = before.clone();
let mut sorted: Vec = offsets.to_vec();
@@ -520,13 +638,23 @@ pub fn remove_at_offsets(events: &mut Vec>, text_element: &str, o
}
}
- replace_content(events, &ranges, &before, &runs);
+ replace_content(events, ranges, &before, &runs);
}
/// Strip a leading marker character, and the whitespace after it, from the
/// start of the element's first run.
pub fn strip_leading_marker(events: &mut Vec>, text_element: &str, marker: char) {
let ranges = text_content_ranges(events, text_element);
+ strip_leading_marker_in(events, &ranges, marker);
+}
+
+/// [`strip_leading_marker`], over a caller-chosen set of content ranges. See
+/// [`map_runs_in`] for when a caller has to choose them.
+pub fn strip_leading_marker_in(
+ events: &mut Vec>,
+ ranges: &[std::ops::Range],
+ marker: char,
+) {
let Some(first) = ranges.first().cloned() else {
return;
};
diff --git a/crates/mirsam-ooxml/src/word.rs b/crates/mirsam-ooxml/src/word.rs
new file mode 100644
index 0000000..726d0fc
--- /dev/null
+++ b/crates/mirsam-ooxml/src/word.rs
@@ -0,0 +1,686 @@
+//! WordprocessingML's repair vocabulary: which element and which attribute
+//! each `Fix` lands on, and where a created element goes.
+//!
+//! [`crate::rewrite`] is this module's opposite number for DrawingML and
+//! [`crate::sheet`] Excel's; the three share [`crate::token`] and
+//! [`crate::package`] and not one element name. The guarantee is the same at
+//! both scales: a part no repair touches survives byte for byte, and inside a
+//! part that *is* rewritten, a token no repair addresses comes out as it went
+//! in.
+//!
+//! ## Word's alignment is already direction-relative, so nothing is lowered
+//!
+//! `a:pPr/@algn` names physical edges, so the PowerPoint rewriter has to be
+//! told the direction each paragraph reads in before it can write a `Start`
+//! down — that is what [`crate::rewrite::Inherited`] is for, and getting it
+//! wrong reproduces the defect being repaired.
+//!
+//! **This module needs no such map, and that is Word's doing rather than a
+//! shortcut.** `w:jc`'s values are evaluated against the paragraph's own
+//! `w:bidi` ([MS-OE376] Part 4 §2.3.1.13, note b), so `Start` is written as
+//! `start` whichever way the paragraph runs and stays the start edge if the
+//! direction is changed afterwards. The same fact read from the other side is
+//! why [`crate::docx`] never produces `Alignment::Left`: a physical edge is
+//! not a thing WordprocessingML can say, so [`Alignment::Left`] and
+//! [`Alignment::Right`] are *refused* here rather than lowered onto the edge
+//! they happen to mean today. The conformance suite states that refusal for
+//! the reading side, and this is its writing half.
+//!
+//! ## `` is on, so left-to-right must be written out
+//!
+//! An `ST_OnOff` element with no `w:val` is true, which is the form Word
+//! writes far more often than the explicit one. A repair setting a paragraph
+//! left-to-right therefore writes `w:val="0"` and never an empty element:
+//! creating `` to mean *off* would turn the repair into the defect.
+//!
+//! ## A typed bullet becomes a list this document already has
+//!
+//! DrawingML answers `ConvertLiteralBullet` with `a:buChar`, an attribute on
+//! the paragraph. Word has no such thing: a list is a `w:numPr` pointing into
+//! `word/numbering.xml`, and a paragraph cannot carry the definition itself.
+//! [`bullet_list`] finds a definition the document already stores — preferring
+//! one that draws the marker the author typed — and the repair points at it.
+//! A document with no bulleted list at all is *refused*, because
+//! [`crate::package`] rewrites the entries a package has and creating
+//! `word/numbering.xml`, its content-type override and its relationship is
+//! three new parts rather than an edit.
+//!
+//! ## Which runs belong to the paragraph being repaired
+//!
+//! A `w:txbxContent` nests whole paragraphs inside a run, and each of them is
+//! a unit in its own right with its own text, its own ordinal and its own
+//! repairs. An `mc:Fallback` spells out the same paragraphs as the
+//! `mc:Choice` beside it and [`crate::docx`] reads neither it nor them. So
+//! both are stepped over here — when the elements are counted, so a repair
+//! lands on the paragraph the report named, and when a paragraph's runs are
+//! edited, so an offset the domain computed cannot reach text nobody scanned.
+//!
+//! [MS-OE376]: https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/26ecf09a-0f0b-4574-9907-ebd1ddf3015f
+
+use crate::token::{
+ self, child_or_insert, edit_tag, element_range, element_ranges_outside, find_direct_child,
+ get_attribute, remove_attribute, set_attribute,
+};
+use mirsam_core::Fix;
+use mirsam_core::error::{Error, Result};
+use mirsam_core::script;
+use mirsam_core::text::{Alignment, Direction};
+use quick_xml::events::Event;
+use std::collections::BTreeMap;
+use std::ops::Range;
+
+/// Repairs for one part, keyed by paragraph index.
+///
+/// The index counts every `w:p` in the part, 1-based, exactly as
+/// [`crate::docx`] counts them — including the paragraphs that produced no
+/// text unit, and excluding everything inside an `mc:Fallback`, so the two
+/// numberings cannot drift.
+pub type PartFixes = BTreeMap>;
+
+/// Repairs for one part's tables, keyed by table index: every `w:tbl` in the
+/// part, 1-based, as the scanner counts them.
+pub type TableFixes = BTreeMap>;
+
+/// The list a typed marker is converted to, by the marker that was typed.
+///
+/// Supplied by the adapter, which is the half of this that can read
+/// `word/numbering.xml`; see [`bullet_list`].
+pub type Bullets = BTreeMap;
+
+/// Everything to change in one part.
+#[derive(Debug, Default, Clone)]
+pub struct PartPlan {
+ pub paragraphs: PartFixes,
+ pub tables: TableFixes,
+}
+
+impl PartPlan {
+ /// How many repairs the plan carries.
+ pub fn len(&self) -> usize {
+ [&self.paragraphs, &self.tables]
+ .into_iter()
+ .flat_map(|fixes| fixes.values())
+ .map(Vec::len)
+ .sum()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+}
+
+// ---------------------------------------------------------------- schema order
+
+/// `CT_PPr`, in schema sequence order: `CT_PPrBase` followed by the paragraph
+/// mark's run properties, the section properties and the revision record.
+const PPR_ORDER: &[&str] = &[
+ "w:pStyle",
+ "w:keepNext",
+ "w:keepLines",
+ "w:pageBreakBefore",
+ "w:framePr",
+ "w:widowControl",
+ "w:numPr",
+ "w:suppressLineNumbers",
+ "w:pBdr",
+ "w:shd",
+ "w:tabs",
+ "w:suppressAutoHyphens",
+ "w:kinsoku",
+ "w:wordWrap",
+ "w:overflowPunct",
+ "w:topLinePunct",
+ "w:autoSpaceDE",
+ "w:autoSpaceDN",
+ "w:bidi",
+ "w:adjustRightInd",
+ "w:snapToGrid",
+ "w:spacing",
+ "w:ind",
+ "w:contextualSpacing",
+ "w:mirrorIndents",
+ "w:suppressOverlap",
+ "w:jc",
+ "w:textDirection",
+ "w:textAlignment",
+ "w:textboxTightWrap",
+ "w:outlineLvl",
+ "w:divId",
+ "w:cnfStyle",
+ "w:rPr",
+ "w:sectPr",
+ "w:pPrChange",
+];
+
+/// `CT_RPr`, in schema sequence order, with the four revision markers
+/// `CT_ParaRPr` puts in front of the same sequence.
+///
+/// One list for both, because the paragraph mark's run properties and a run's
+/// carry the same elements in the same order; the markers simply never occur
+/// in a `w:rPr` that is not a paragraph mark's, and an element that never
+/// occurs cannot be ranked wrongly.
+const RPR_ORDER: &[&str] = &[
+ "w:ins",
+ "w:del",
+ "w:moveFrom",
+ "w:moveTo",
+ "w:rStyle",
+ "w:rFonts",
+ "w:b",
+ "w:bCs",
+ "w:i",
+ "w:iCs",
+ "w:caps",
+ "w:smallCaps",
+ "w:strike",
+ "w:dstrike",
+ "w:outline",
+ "w:shadow",
+ "w:emboss",
+ "w:imprint",
+ "w:noProof",
+ "w:snapToGrid",
+ "w:vanish",
+ "w:webHidden",
+ "w:color",
+ "w:spacing",
+ "w:w",
+ "w:kern",
+ "w:position",
+ "w:sz",
+ "w:szCs",
+ "w:highlight",
+ "w:u",
+ "w:effect",
+ "w:bdr",
+ "w:shd",
+ "w:fitText",
+ "w:vertAlign",
+ "w:rtl",
+ "w:cs",
+ "w:em",
+ "w:lang",
+ "w:eastAsianLayout",
+ "w:specVanish",
+ "w:oMath",
+ "w:rPrChange",
+];
+
+/// `CT_TblPrBase`, in schema sequence order, with the revision record
+/// `CT_TblPr` adds after it.
+const TBLPR_ORDER: &[&str] = &[
+ "w:tblStyle",
+ "w:tblpPr",
+ "w:tblOverlap",
+ "w:bidiVisual",
+ "w:tblStyleRowBandSize",
+ "w:tblStyleColBandSize",
+ "w:tblW",
+ "w:jc",
+ "w:tblCellSpacing",
+ "w:tblInd",
+ "w:tblBorders",
+ "w:shd",
+ "w:tblLayout",
+ "w:tblCellMar",
+ "w:tblLook",
+ "w:tblCaption",
+ "w:tblDescription",
+ "w:tblPrChange",
+];
+
+/// `CT_Tbl`: the properties, then the grid, then the rows.
+const TBL_ORDER: &[&str] = &["w:tblPr", "w:tblGrid", "w:tr"];
+
+/// `CT_NumPr`, in schema sequence order.
+const NUMPR_ORDER: &[&str] = &["w:ilvl", "w:numId", "w:numberingChange", "w:ins"];
+
+/// `CT_P`: `w:pPr` precedes every run.
+const P_ORDER: &[&str] = &["w:pPr"];
+
+/// `CT_R`: `w:rPr` precedes the run's content.
+const R_ORDER: &[&str] = &["w:rPr"];
+
+/// The element a run's characters live in.
+const W_T: &str = "w:t";
+
+/// Regions of a part whose paragraphs are not the paragraph being repaired:
+/// the text boxes that nest whole units inside a run, and the fallback
+/// [`crate::docx`] steps over.
+const NESTED: &[&str] = &["w:txbxContent", "mc:Fallback"];
+
+/// Regions of a part that [`crate::docx`] does not count, and so regions this
+/// module must not count either.
+///
+/// A `w:txbxContent` is missing on purpose: the paragraphs in a text box *are*
+/// counted by the scanner and are units of their own, and only their text and
+/// their run properties belong to the inner paragraph rather than the outer.
+const UNCOUNTED: &[&str] = &["mc:Fallback"];
+
+// ------------------------------------------------------------------- lexemes
+
+/// An `ST_OnOff` value, written out in full.
+///
+/// Never the empty element: `` is *on*, so a left-to-right repair has
+/// to say `w:val="0"` or it writes the opposite of what it was asked for.
+fn on_off(direction: Direction) -> &'static str {
+ if direction == Direction::Rtl {
+ "1"
+ } else {
+ "0"
+ }
+}
+
+/// Lower an alignment onto `w:jc`, which names the paragraph's own start and
+/// end edges rather than the page's.
+///
+/// `None` for a physical edge. Word evaluates `left` and `right` against the
+/// paragraph's `w:bidi`, so there is no value here that means "the left of the
+/// page whatever the direction"; writing the relative spelling that happens to
+/// land left today would be answering a different question from the one asked,
+/// and one that comes apart the moment the direction changes.
+fn word_alignment(alignment: Alignment) -> Option<&'static str> {
+ Some(match alignment {
+ Alignment::Start => "start",
+ Alignment::End => "end",
+ Alignment::Center => "center",
+ Alignment::Justify => "both",
+ Alignment::Distributed => "distribute",
+ Alignment::Left | Alignment::Right => return None,
+ })
+}
+
+// ------------------------------------------------------------------ numbering
+
+/// One attribute of the tag at `at`.
+fn attribute(events: &[Event<'static>], at: usize, name: &str) -> Option {
+ match &events[at] {
+ Event::Start(tag) | Event::Empty(tag) => get_attribute(tag, name),
+ _ => None,
+ }
+}
+
+/// The direct children of the element at `at`, as absolute indices.
+fn children(events: &[Event<'static>], at: usize) -> Vec {
+ let range = element_range(events, at);
+ token::direct_children(&events[range.clone()])
+ .into_iter()
+ .map(|i| range.start + i)
+ .collect()
+}
+
+/// The `w:numId` of a list in `word/numbering.xml` that draws a bullet,
+/// preferring one whose first level draws `marker` itself.
+///
+/// A repair converting a typed glyph into a real list has to point at a
+/// definition, and this is the only honest place to get one: the definitions
+/// the document already carries. Preferring the marker the author typed keeps
+/// the repaired page looking like the page they were aiming at; falling back
+/// to any bulleted list keeps the repair possible when it does not, because
+/// the defect being fixed is the glyph in the text and not which glyph it is.
+///
+/// `None` when the document defines no bulleted list, which is a refusal
+/// rather than a licence to invent one: a `w:num` this module wrote would name
+/// an `w:abstractNum` it also wrote, and neither is a repair to a paragraph.
+///
+/// A `w:lvlOverride` inside a `w:num` is not read. It can restate a level's
+/// format, and a definition this function accepted on the strength of its
+/// abstract while an override said otherwise would be a list drawing something
+/// else — so only a `w:num` whose abstract says `bullet` and which overrides
+/// nothing at level zero is offered.
+pub fn bullet_list(part: &str, xml: &str, marker: char) -> Result