diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3628564..2409154d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -79,6 +79,44 @@ All notable changes to this project will be documented in this file.
evaluation, counting, and the whole multi-match rewrite share one mutation gate
and one undo snapshot, so duplicate text cannot turn a stale plan into a partial
replacement.
+- **Complete inspect-before-edit formatting surface (#448).** `DocxSession` now exposes an explicit
+ style catalog (`ListStyles`), direct-versus-effective paragraph/run formatting
+ (`GetFormatting`), and enumerable mutation-compatible run spans (`ListInlineSpans`). Effective
+ properties reuse `FormattingAssembler`'s document-default and style-chain rollups. List
+ membership now includes its query `AnchorId`, abstract-level `Start`/`LevelText`, and effective
+ indentation; section info includes its mutation-ready body `AnchorId`, and section identifiers
+ are stored Unids rather than positional fallbacks. The same JSON schema is rippled through the
+ WASM/npm, stdio/Python, and MCP surfaces (`get_content` formats `styles`, `formatting`, `spans`;
+ `info` is per-anchor). Returned style ids, anchors, and spans are tested by feeding them unchanged
+ into their matching mutation APIs. Table geometry and inline memberships remain separate work.
+
+ **Breaking (source):** `ListMembership` and `SectionInfo` gained a `required` `AnchorId`
+ property. External code that constructs either record with an object initializer must now set
+ it; consumers that only read the records are unaffected.
+
+ **Known limitation.** "Effective" is a shorter cascade than the render oracle: it excludes the
+ numbering-level `w:pPr` and the table-style/`w:tblStylePr` layers, so a list item whose indent
+ lives only in `w:abstractNum/w:lvl/w:pPr/w:ind` reports `LeftIndentTwips = 0`, and a run bolded
+ by a `firstRow` table style reports `Bold = false`. Both are pinned by tests and stated in
+ `docs/architecture/docx_mutation_api.md`; `GetListMembership` exposes the real numbering
+ indentation in the meantime.
+- **Style/formatting introspection is annotation-independent and cycle-safe.** Three fixes to the
+ new read surface: effective paragraph properties no longer vary with whether
+ `ListItemRetriever`'s (lazily cached) list annotations happen to be present, so `GetFormatting`
+ answers the same for a projected and an unprojected session and cannot be changed by an
+ intervening `GetListMembership`; `ST_OnOff` values now parse through `PtUtil.ToBoolean` — the
+ parser the renderer uses — so `w:val="False"` reads as false instead of true, and a value outside
+ `ST_OnOff` reads as unknown instead of true (this also aligns `IsDefault`/`SemiHidden`/
+ `QuickFormat` and the default-paragraph-style lookup with the resolver that consumes `w:default`);
+ and `FormattingAssembler`'s four `w:basedOn` walkers gained cycle guards, so a document declaring
+ `A basedOn A` (or `A → B → A`) no longer hangs `ListStyles`, which rolls up every style in the
+ catalog including ones no content references.
+- **Block renders stamp `data-source-anchor-id`.** `RenderBlocksHtml` / `RenderBlockHtml` now carry
+ the canonical source anchor id from the original package onto the rendered block, matching the
+ full render. Previously only the full and paginated renders emitted it, so an incrementally
+ re-rendered block silently dropped the addressing attribute `npm/src/pagination.ts` resolves page
+ citations by. The id is carried, never re-derived from the throwaway shell — a shell hoists note
+ paragraphs into its body and would otherwise stamp a `body`-scoped id onto footnote content.
- **`DocxSessionSettings.UndoMemoryBudgetBytes`** (wire `undoMemoryBudgetBytes`,
Python `undo_memory_budget_bytes`) — an approximate ceiling on the memory held
by undo/redo snapshots, default **128 MiB**. `UndoDepth` never bounded memory:
diff --git a/Docxodus.Tests/DocxSessionMetadataTests.cs b/Docxodus.Tests/DocxSessionMetadataTests.cs
index dd829eee..ef034500 100644
--- a/Docxodus.Tests/DocxSessionMetadataTests.cs
+++ b/Docxodus.Tests/DocxSessionMetadataTests.cs
@@ -4,7 +4,13 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System.Linq;
+using System.IO;
+using System.Security.Cryptography;
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Wordprocessing;
using Docxodus;
+using Docxodus.Internal;
using Xunit;
namespace Docxodus.Tests;
@@ -172,4 +178,469 @@ public void BM010_HasInlineFormatting_DetectsBoldRun()
Assert.True(session.GetBlockMetadata(anchor.Anchor.Id)!.HasInlineFormatting);
}
+
+ [Fact]
+ public void BM011_ListStyles_ResolvesInheritanceLatentMetadata_AndIdsRoundTrip()
+ {
+ using var session = new DocxSession(BuildFormattingIntrospectionDocument());
+ var styles = session.ListStyles();
+
+ var child = Assert.Single(styles, s => s.Id == "ChildPara");
+ Assert.Equal("BasePara", child.BasedOn);
+ Assert.Equal("Normal", child.Next);
+ Assert.Equal(7, child.UiPriority);
+ Assert.True(child.QuickFormat);
+ Assert.Equal(720, child.ResolvedParagraph!.LeftIndentTwips);
+ Assert.Equal(200, child.ResolvedParagraph.SpacingAfterTwips);
+ Assert.True(child.ResolvedRun!.Bold);
+ Assert.True(child.ResolvedRun.Italic);
+
+ var character = Assert.Single(styles, s => s.Id == "StrongCustom");
+ Assert.Equal("EmphasisBase", character.BasedOn);
+ Assert.Equal(4, character.UiPriority);
+ Assert.True(character.SemiHidden);
+ Assert.True(character.QuickFormat);
+ Assert.True(character.ResolvedRun!.Bold);
+ Assert.True(character.ResolvedRun.Italic);
+
+ var table = Assert.Single(styles, s => s.Id == "AgentTable");
+ Assert.Equal("center", table.ResolvedTable!.Alignment);
+ Assert.Equal(5000, table.ResolvedTable.WidthTwips);
+ Assert.True(table.ResolvedTable.HasBorders);
+
+ var anchor = session.Project().AnchorIndex.Values.First(v => v.Anchor.Scope == "body").Anchor.Id;
+ Assert.True(session.SetParagraphStyle(anchor, child.Id).Success);
+ var span = Assert.Single(session.ListInlineSpans(anchor), s => s.Text == "Alpha");
+ Assert.True(session.ApplyFormat(span.AnchorId, span.Span,
+ new FormatOp { RunStyle = character.Id }).Success);
+ }
+
+ [Fact]
+ public void BM012_GetFormatting_DistinguishesDirectFromEffective_AndSpansRoundTrip()
+ {
+ using var session = new DocxSession(BuildFormattingIntrospectionDocument());
+ var anchor = session.Project().AnchorIndex.Values.First(v => v.Anchor.Scope == "body").Anchor.Id;
+
+ var formatting = session.GetFormatting(anchor);
+
+ Assert.NotNull(formatting);
+ Assert.Equal("ChildPara", formatting!.DirectParagraph.StyleId);
+ Assert.Equal(ParagraphAlignment.Right, formatting.DirectParagraph.Alignment);
+ Assert.Null(formatting.DirectParagraph.LeftIndentTwips);
+ Assert.Equal(720, formatting.EffectiveParagraph.LeftIndentTwips);
+ Assert.Equal(300, formatting.EffectiveParagraph.SpacingAfterTwips);
+ Assert.Equal(ParagraphAlignment.Right, formatting.EffectiveParagraph.Alignment);
+
+ var alpha = Assert.Single(formatting.Runs, s => s.Text == "Alpha");
+ Assert.Equal("StrongCustom", alpha.Direct.StyleId);
+ Assert.False(alpha.Direct.Italic);
+ Assert.Null(alpha.Direct.Bold);
+ Assert.True(alpha.Effective.Bold);
+ Assert.False(alpha.Effective.Italic);
+ Assert.Equal(12, alpha.Effective.FontSizePts);
+ Assert.Equal(anchor, alpha.AnchorId);
+
+ Assert.True(session.ApplyFormat(alpha.AnchorId, alpha.Span,
+ new FormatOp { Underline = true }).Success);
+ var refreshed = Assert.Single(session.ListInlineSpans(anchor), s => s.Text == "Alpha");
+ Assert.True(refreshed.Direct.Underline);
+ }
+
+ [Fact]
+ public void BM013_ListMembership_ReportsDefinitionStartIndent_AndMutationAnchor()
+ {
+ using var session = new DocxSession(DocxSessionTests.BuildBM_StyleInheritedList());
+ var anchor = session.Project().AnchorIndex.Values.Single(v => v.Anchor.Kind == "li").Anchor.Id;
+
+ var list = session.GetListMembership(anchor)!;
+
+ Assert.Equal(anchor, list.AnchorId);
+ Assert.True(list.FromStyle);
+ Assert.Equal(3, list.Start);
+ Assert.Equal("·", list.LevelText);
+ Assert.Equal(720, list.LeftIndentTwips);
+ Assert.Equal(360, list.HangingIndentTwips);
+ Assert.True(session.SetListStartOverride(list.AnchorId, 9).Success);
+ Assert.Equal(9, session.GetListMembership(list.AnchorId)!.StartOverride);
+ }
+
+ [Fact]
+ public void BM014_GetSectionInfo_IsPerAnchor_AndSectionIdsStayStableAcrossMutation()
+ {
+ using var session = new DocxSession(BuildMixedSectionsDocument());
+ var anchors = session.Project().AnchorIndex.Values
+ .Where(v => v.Anchor.Scope == "body" && v.Anchor.Kind == "p")
+ .Select(v => v.Anchor.Id).ToArray();
+
+ var first = session.GetSectionInfo(anchors[0])!;
+ var second = session.GetSectionInfo(anchors[1])!;
+
+ Assert.Equal(anchors[0], first.AnchorId);
+ Assert.Equal(anchors[1], second.AnchorId);
+ Assert.NotEqual(first.SectionUnid, second.SectionUnid);
+ Assert.Equal(10000, first.PageWidthTwips);
+ Assert.Equal(14000, second.PageWidthTwips);
+
+ Assert.True(session.SetPageNumbering(second.AnchorId,
+ new PageNumberingOp { Start = 4 }).Success);
+ Assert.Equal(first.SectionUnid, session.GetSectionInfo(first.AnchorId)!.SectionUnid);
+ Assert.Equal(second.SectionUnid, session.GetSectionInfo(second.AnchorId)!.SectionUnid);
+ }
+
+ [Fact]
+ public void BM015_Introspection_IsBytePure_AndIdsSurviveSaveReopen()
+ {
+ var input = BuildFormattingIntrospectionDocument();
+ string anchorId;
+ string runUnid;
+ string sectionUnid;
+ string[] styleIds;
+ byte[] saved;
+
+ using (var session = new DocxSession(input))
+ {
+ anchorId = session.Project().AnchorIndex.Values
+ .First(v => v.Anchor.Scope == "body" && v.Anchor.Kind == "p").Anchor.Id;
+ var before = SHA256.HashData(session.Save(persistAnchorIds: true));
+
+ styleIds = session.ListStyles().Select(s => s.Id).ToArray();
+ var formatting = session.GetFormatting(anchorId)!;
+ var spans = session.ListInlineSpans(anchorId);
+ var section = session.GetSectionInfo(anchorId)!;
+
+ Assert.Equal(formatting.Runs.Select(s => s.RunUnid), spans.Select(s => s.RunUnid));
+ runUnid = spans[0].RunUnid;
+ sectionUnid = section.SectionUnid;
+
+ Assert.Equal(runUnid, session.ListInlineSpans(anchorId)[0].RunUnid);
+ Assert.Equal(sectionUnid, session.GetSectionInfo(anchorId)!.SectionUnid);
+ Assert.Equal(before, SHA256.HashData(session.Save(persistAnchorIds: true)));
+
+ saved = session.Save();
+ }
+
+ using var reopened = new DocxSession(saved);
+ Assert.Equal(styleIds, reopened.ListStyles().Select(s => s.Id).ToArray());
+ Assert.NotNull(reopened.GetFormatting(anchorId));
+ Assert.Equal(runUnid, reopened.ListInlineSpans(anchorId)[0].RunUnid);
+ Assert.Equal(sectionUnid, reopened.GetSectionInfo(anchorId)!.SectionUnid);
+ }
+
+ [Fact]
+ public void BM016_DeterministicInspectionFallbacks_DoNotWriteXml()
+ {
+ using var stream = new MemoryStream(BuildFormattingIntrospectionDocument());
+ using var doc = WordprocessingDocument.Open(stream, true);
+ var main = doc.MainDocumentPart!;
+ var root = main.GetXDocument().Root!;
+ var paragraph = root.Descendants(W.p).First();
+ paragraph.SetAttributeValue(PtOpenXml.Unid, "testanchor");
+ var run = paragraph.Elements(W.r).First();
+ var sectPr = root.Descendants(W.sectPr).First();
+ var target = new AnchorTarget
+ {
+ Anchor = new Anchor("p:body:testanchor", "p", "body", "testanchor"),
+ PartUri = main.Uri.ToString(),
+ Unid = "testanchor",
+ TextPreview = "Alpha beta",
+ };
+ var before = root.ToString(System.Xml.Linq.SaveOptions.DisableFormatting);
+
+ _ = FormattingIntrospectionOps.ListStyles(doc);
+ _ = FormattingIntrospectionOps.GetFormatting(doc, target);
+ var firstSpans = FormattingIntrospectionOps.ListInlineSpans(doc, target);
+ var firstSection = BlockMetadataOps.GetSectionInfo(doc, target)!;
+ var secondSpans = FormattingIntrospectionOps.ListInlineSpans(doc, target);
+ var secondSection = BlockMetadataOps.GetSectionInfo(doc, target)!;
+
+ Assert.Equal(before, root.ToString(System.Xml.Linq.SaveOptions.DisableFormatting));
+ Assert.Null(run.Attribute(PtOpenXml.Unid));
+ Assert.Null(sectPr.Attribute(PtOpenXml.Unid));
+ Assert.Equal(firstSpans[0].RunUnid, secondSpans[0].RunUnid);
+ Assert.Equal(firstSection.SectionUnid, secondSection.SectionUnid);
+
+ Assert.True(UnidHelper.AssignToAllElementsDeterministic(root));
+ Assert.Equal(firstSpans[0].RunUnid, (string?)run.Attribute(PtOpenXml.Unid));
+ Assert.Equal(firstSection.SectionUnid, (string?)sectPr.Attribute(PtOpenXml.Unid));
+ }
+
+ // A pure read must not be able to change a later read's answer. GetListMembership resolves
+ // the generated label through ListItemRetriever, which plants ListItemInfo annotations on
+ // the LIVE paragraphs; ParagraphStyleRollup folds an extra numbering-level pPr layer in
+ // whenever it sees one. GetFormatting must be blind to that.
+ [Fact]
+ public void BM017_GetFormatting_IsUnaffectedByAnInterveningListMembershipRead()
+ {
+ var input = DocxSessionTests.BuildBM_StyleInheritedList();
+ string anchor;
+ using (var probe = new DocxSession(input))
+ {
+ anchor = probe.Project().AnchorIndex.Values.Single(v => v.Anchor.Kind == "li").Anchor.Id;
+ }
+
+ using var session = new DocxSession(input);
+ var before = session.GetFormatting(anchor)!;
+ Assert.NotNull(session.GetListMembership(anchor));
+ var after = session.GetFormatting(anchor)!;
+
+ Assert.Equal(before.EffectiveParagraph, after.EffectiveParagraph);
+ Assert.Equal(before.DirectParagraph, after.DirectParagraph);
+ Assert.Equal(
+ before.Runs.Select(r => r.Effective),
+ after.Runs.Select(r => r.Effective));
+ }
+
+ // The same invariant across the OTHER channel that plants those annotations: Project()
+ // enriches (and annotates), the cheap BuildAnchorIndexOnly path does not. Two sessions over
+ // identical bytes must resolve identical effective formatting. This is the assertion that
+ // discriminates "resolver is annotation-independent" from "the read happens to clean up".
+ [Fact]
+ public void BM018_GetFormatting_IsIdenticalOnProjectedAndUnprojectedSessions()
+ {
+ var input = DocxSessionTests.BuildBM_StyleInheritedList();
+ using var projected = new DocxSession(input);
+ var anchor = projected.Project().AnchorIndex.Values.Single(v => v.Anchor.Kind == "li").Anchor.Id;
+ var fromProjected = projected.GetFormatting(anchor)!;
+
+ using var unprojected = new DocxSession(input);
+ var fromUnprojected = unprojected.GetFormatting(anchor)!;
+
+ Assert.Equal(fromProjected.EffectiveParagraph, fromUnprojected.EffectiveParagraph);
+ // Pinned direction: BOTH report the cascade documented in docx_mutation_api.md, which
+ // excludes the numbering level. See BM021 for the number this costs.
+ Assert.Equal(0, fromProjected.EffectiveParagraph.LeftIndentTwips);
+ }
+
+ // ST_OnOff is case-INSENSITIVE and its parser is PtUtil.ToBoolean — the one the renderer
+ // uses. A writer emitting bool.ToString() ("False") must not be reported as bold, and a
+ // value outside ST_OnOff must not be reported as bold either, nor throw.
+ [Fact]
+ public void BM019_OnOffValues_AreParsedCaseInsensitively_AndGarbageIsNotTrue()
+ {
+ using var session = new DocxSession(BuildOnOffCaseDocument());
+ var anchor = session.Project().AnchorIndex.Values
+ .Single(v => v.Anchor.Scope == "body" && v.Anchor.Kind == "p").Anchor.Id;
+
+ var spans = session.ListInlineSpans(anchor);
+ var mixedCase = Assert.Single(spans, s => s.Text == "Alpha");
+ var upperOnOff = Assert.Single(spans, s => s.Text == "Beta");
+
+ Assert.False(mixedCase.Direct.Bold);
+ Assert.False(mixedCase.Effective.Bold);
+ Assert.True(mixedCase.Direct.Italic);
+ Assert.False(upperOnOff.Direct.Bold);
+ Assert.True(upperOnOff.Direct.Italic);
+
+ // A value outside ST_OnOff is UNKNOWN, never "on". (Asserted on a paragraph toggle:
+ // FormattingAssembler.CharStyleAttributes.GetBoolProperty throws on an out-of-spec RUN
+ // toggle, which is pre-existing engine strictness this read API inherits.)
+ var formatting = session.GetFormatting(anchor)!;
+ Assert.Null(formatting.DirectParagraph.KeepNext);
+ Assert.False(formatting.EffectiveParagraph.KeepNext);
+ Assert.True(formatting.DirectParagraph.KeepLines);
+
+ // w:default="True" must resolve for the catalog and for the effective-style lookup the
+ // same way FormattingAssembler's own default-style scan (already .ToBoolean()) does.
+ var normal = Assert.Single(session.ListStyles(), s => s.Id == "Normal");
+ Assert.True(normal.IsDefault);
+ Assert.Equal("Normal", session.GetFormatting(anchor)!.EffectiveParagraph.StyleId);
+ }
+
+ // w:basedOn is caller data, not a guaranteed tree. ListStyles rolls up EVERY style in the
+ // catalog, including ones no content references, so a self- or mutually-based style reaches
+ // the walkers. Terminating (and returning what accumulated) is the contract.
+ [Fact]
+ public void BM020_ListStyles_TerminatesOnCyclicBasedOnChains()
+ {
+ using var session = new DocxSession(BuildCyclicBasedOnDocument());
+
+ var styles = session.ListStyles();
+
+ Assert.Equal(240, Assert.Single(styles, s => s.Id == "SelfCycle").ResolvedParagraph!.LeftIndentTwips);
+ Assert.Equal(60, Assert.Single(styles, s => s.Id == "LoopA").ResolvedParagraph!.SpacingAfterTwips);
+ Assert.True(Assert.Single(styles, s => s.Id == "CharCycle").ResolvedRun!.Bold);
+ Assert.Equal(4000, Assert.Single(styles, s => s.Id == "TableCycle").ResolvedTable!.WidthTwips);
+ }
+
+ // DOCUMENTED LIMITATION (not a desired behaviour): the effective-paragraph cascade is
+ // docDefaults + pStyle chain + direct pPr. It does NOT include the numbering level's own
+ // w:pPr, which is where a list item's indentation normally lives — so the render of this
+ // same paragraph is indented 720 twips and introspection reports 0. GetListMembership
+ // surfaces the real numbers separately. Unifying the two cascades is deferred; when it
+ // lands, these numbers change and this test is the target.
+ [Fact]
+ public void BM021_EffectiveParagraph_ExcludesTheNumberingLevelIndent_KnownLimitation()
+ {
+ using var session = new DocxSession(DocxSessionTests.BuildBM_StyleInheritedList());
+ var anchor = session.Project().AnchorIndex.Values.Single(v => v.Anchor.Kind == "li").Anchor.Id;
+
+ var membership = session.GetListMembership(anchor)!;
+ var formatting = session.GetFormatting(anchor)!;
+
+ Assert.Equal(720, membership.LeftIndentTwips);
+ Assert.Equal(360, membership.HangingIndentTwips);
+ Assert.Equal(0, formatting.EffectiveParagraph.LeftIndentTwips);
+ Assert.Equal(0, formatting.EffectiveParagraph.HangingIndentTwips);
+ }
+
+ // DOCUMENTED LIMITATION (not a desired behaviour): the effective-run cascade is
+ // docDefaults + character/paragraph style chain + direct rPr + theme fonts. It does NOT
+ // toggle-merge the table style's conditional rPr (w:tblStylePr), so a run in a firstRow-
+ // styled table renders bold but introspects as not bold. Deferred with BM021.
+ [Fact]
+ public void BM022_EffectiveRun_ExcludesConditionalTableStyleFormatting_KnownLimitation()
+ {
+ using var session = new DocxSession(BuildConditionalTableStyleDocument());
+ var anchor = session.Project().AnchorIndex.Values
+ .First(v => v.Anchor.Scope == "body" && v.Anchor.Kind == "p"
+ && v.TextPreview.Contains("Header cell")).Anchor.Id;
+
+ var span = Assert.Single(session.ListInlineSpans(anchor));
+
+ Assert.Null(span.Direct.Bold);
+ Assert.False(span.Effective.Bold);
+ }
+
+ ///
+ /// A document written as literal XML, so ST_OnOff values keep the exact lexical form
+ /// under test (the SDK's OnOffValue would normalize "False" to "false").
+ ///
+ private static byte[] BuildLiteralXmlDocument(string bodyXml, string stylesXml)
+ {
+ using var stream = new MemoryStream();
+ using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
+ {
+ var main = doc.AddMainDocumentPart();
+ using (var writer = new StreamWriter(main.GetStream(FileMode.Create, FileAccess.Write)))
+ {
+ writer.Write(
+ ""
+ + bodyXml + "");
+ }
+
+ main.AddNewPart().Settings = new Settings();
+ var styles = main.AddNewPart();
+ using (var writer = new StreamWriter(styles.GetStream(FileMode.Create, FileAccess.Write)))
+ {
+ writer.Write(stylesXml);
+ }
+ }
+ return stream.ToArray();
+ }
+
+ private static byte[] BuildOnOffCaseDocument() => BuildLiteralXmlDocument(
+ """
+
+
+
+ Alpha
+ Beta
+
+
+
+ """,
+ """
+
+
+
+ """);
+
+ private static byte[] BuildCyclicBasedOnDocument() => BuildLiteralXmlDocument(
+ """
+
+ Body
+
+
+ """,
+ """
+
+
+
+
+
+
+
+
+ """);
+
+ private static byte[] BuildConditionalTableStyleDocument() => BuildLiteralXmlDocument(
+ """
+
+
+
+
+ Header cell
+ Body cell
+
+ After table
+
+
+ """,
+ """
+
+
+
+
+ """);
+
+ private static byte[] BuildFormattingIntrospectionDocument()
+ {
+ using var stream = new MemoryStream();
+ using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
+ {
+ var main = doc.AddMainDocumentPart();
+ main.Document = new Document(new Body(
+ new Paragraph(
+ new ParagraphProperties(
+ new ParagraphStyleId { Val = "ChildPara" },
+ new Justification { Val = JustificationValues.Right },
+ new SpacingBetweenLines { After = "300" }),
+ new Run(
+ new RunProperties(
+ new RunStyle { Val = "StrongCustom" },
+ new Italic { Val = false }),
+ new Text("Alpha")),
+ new Run(new Text(" beta"))),
+ new SectionProperties(new PageSize { Width = 12240, Height = 15840 })));
+ main.AddNewPart().Settings = new Settings();
+
+ var styles = main.AddNewPart();
+ using (var writer = new StreamWriter(styles.GetStream(FileMode.Create, FileAccess.Write)))
+ {
+ writer.Write("""
+
+
+
+
+
+
+
+
+
+
+ """);
+ }
+ main.Document.Save();
+ }
+ return stream.ToArray();
+ }
+
+ private static byte[] BuildMixedSectionsDocument()
+ {
+ using var stream = new MemoryStream();
+ using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document))
+ {
+ var main = doc.AddMainDocumentPart();
+ main.Document = new Document(new Body(
+ new Paragraph(
+ new ParagraphProperties(new SectionProperties(
+ new PageSize { Width = 10000, Height = 12000 })),
+ new Run(new Text("First section"))),
+ new Paragraph(new Run(new Text("Second section"))),
+ new SectionProperties(new PageSize { Width = 14000, Height = 16000 })));
+ main.AddNewPart().Settings = new Settings();
+ main.Document.Save();
+ }
+ return stream.ToArray();
+ }
}
diff --git a/Docxodus.Tests/DocxSessionTests.cs b/Docxodus.Tests/DocxSessionTests.cs
index 6f45a18d..b5d9e2c2 100644
--- a/Docxodus.Tests/DocxSessionTests.cs
+++ b/Docxodus.Tests/DocxSessionTests.cs
@@ -189,6 +189,11 @@ internal static byte[] BuildBM_StyleInheritedList()
var numberingPart = main.AddNewPart();
numberingPart.Numbering = BuildBulletNumbering();
+ var inheritedLevel = numberingPart.Numbering
+ .Elements().Single().Elements().First();
+ inheritedLevel.StartNumberingValue = new StartNumberingValue { Val = 3 };
+ inheritedLevel.PreviousParagraphProperties = new PreviousParagraphProperties(
+ new Indentation { Left = "720", Hanging = "360" });
// Paragraph carries only the pStyle — no inline numPr.
var pPr = new ParagraphProperties(new ParagraphStyleId { Val = "MyListStyle" });
diff --git a/Docxodus.Tests/HtmlConversionOpsTests.cs b/Docxodus.Tests/HtmlConversionOpsTests.cs
index 9ae77092..2c154704 100644
--- a/Docxodus.Tests/HtmlConversionOpsTests.cs
+++ b/Docxodus.Tests/HtmlConversionOpsTests.cs
@@ -1769,11 +1769,15 @@ public void HCO080_RenderBlockHtml_ResolvesTheAnchorsOwnPart_WhenUnidsCollide()
}
}
- // THE BATCH GATE: RenderBlocksHtml output must be ELEMENT-IDENTICAL to the
+ // THE BATCH GATE: RenderBlocksHtml output must be RENDER-IDENTICAL to the
// corresponding data-anchor element of a full render — including list-item
// markers deep in a list (numbering continuation, the M9 gap the single-block
// path had) and contextualSpacing-dependent margins (neighbor context). This is
- // deliberately stronger than HCO050's tag+text check.
+ // deliberately stronger than HCO050's tag+text check. Canonicalized away: XML attribute
+ // ORDER (no semantics), and the KIND segment of data-source-anchor-id (see
+ // CanonicalizeRenderedFragment — a full-render defect, not a block-render one). The
+ // attribute's addressing dimensions, scope and unid, are still compared exactly, and
+ // HCO083 pins the block path's complete value.
[Fact]
public void HCO081_RenderBlocksHtml_MatchesFullRenderFragments()
{
@@ -1806,16 +1810,89 @@ static bool HasImg(System.Xml.Linq.XElement e) =>
foreach (var id in ids)
{
var unid = id.Substring(id.LastIndexOf(':') + 1);
- var expected = fullByAnchor[unid].ToString(System.Xml.Linq.SaveOptions.DisableFormatting);
+ var expected = System.Xml.Linq.XElement.Parse(
+ fullByAnchor[unid].ToString(System.Xml.Linq.SaveOptions.DisableFormatting));
var actual = map.RootElement.GetProperty(id).GetString();
Assert.NotNull(actual);
- // One extra Parse round-trip on the actual normalizes serializer escaping
- // ( vs the raw NBSP char) — the equality is structural + textual.
- Assert.Equal(expected,
- System.Xml.Linq.XElement.Parse(actual!).ToString(System.Xml.Linq.SaveOptions.DisableFormatting));
+ var actualElement = System.Xml.Linq.XElement.Parse(actual!);
+
+ // XML attribute ORDER has no semantics: the full-document and block serializers
+ // compute independently-sourced attributes in different orders. Sorting them is
+ // the only normalization applied — element identity, text, every attribute name
+ // AND value, style, and child ordering are all compared exactly.
+ var canonicalExpected = CanonicalizeRenderedFragment(expected);
+ var canonicalActual = CanonicalizeRenderedFragment(actualElement);
+ Assert.True(System.Xml.Linq.XNode.DeepEquals(canonicalExpected, canonicalActual),
+ $"block {id} differs:\nexpected: {canonicalExpected}\nactual: {canonicalActual}");
}
}
+ // The PageMap addressing contract specifically: a block render must stamp the SAME
+ // canonical data-source-anchor-id the full render stamps. PM100-PM103 pin this for the
+ // full and paginated paths only; without this assertion nothing pinned the block path,
+ // and npm/src/pagination.ts resolves citations by exactly this attribute.
+ [Fact]
+ public void HCO083_RenderBlocksHtml_StampsCanonicalSourceAnchorIds()
+ {
+ byte[] bytes = File.ReadAllBytes(Path.Combine("..", "..", "..", "..", "TestFiles",
+ "HC031-Complicated-Document.docx"));
+ using var session = new DocxSession(bytes);
+ var options = new HtmlConversionOptions { FabricateCssClasses = false, StampAnchors = true };
+
+ var ids = session.ListBlocks().Body.Where(u => u.Kind is "p" or "li" or "h")
+ .Take(6).Select(u => u.Id).ToList();
+ Assert.True(ids.Count >= 4, $"fixture too thin: only {ids.Count} usable units");
+
+ var json = HtmlConversionOps.RenderBlocksHtml(session, ids, options);
+ using var map = System.Text.Json.JsonDocument.Parse(json);
+ foreach (var id in ids)
+ {
+ var html = map.RootElement.GetProperty(id).GetString();
+ Assert.NotNull(html);
+ var element = System.Xml.Linq.XElement.Parse(html!);
+ Assert.Equal(id, (string?)element.Attribute("data-source-anchor-id"));
+
+ // The stateless byte overload builds its own shell and its own index; it must
+ // carry the same identity rather than re-derive one from the throwaway body.
+ var stateless = System.Xml.Linq.XElement.Parse(
+ HtmlConversionOps.RenderBlockHtml(bytes, id, options));
+ Assert.Equal(id, (string?)stateless.Attribute("data-source-anchor-id"));
+ }
+ }
+
+ private static System.Xml.Linq.XElement CanonicalizeRenderedFragment(
+ System.Xml.Linq.XElement source)
+ {
+ var clone = new System.Xml.Linq.XElement(source);
+ foreach (var element in clone.DescendantsAndSelf())
+ {
+ // data-source-anchor-id: compare SCOPE and UNID exactly, drop the kind segment.
+ // The full render builds its canonical index from the FINAL (post-preprocessing)
+ // trees, and FormattingAssembler's NormalizeListItems has already stripped w:numPr
+ // by then — so WmlToMarkdownConverter.KindFor sees a plain paragraph and every list
+ // item is stamped "p:body:" where the session's own anchor id (the value
+ // PM100 asserts, and the value npm/src/pagination.ts:256 matches citations against)
+ // is "li:body:". Measured on this fixture: 30 of 177 stamped ids, kind-only,
+ // scope and unid always correct. The BLOCK path stamps the session anchor id
+ // verbatim (HCO083), so this exemption covers a full-render defect; delete it — and
+ // watch this assertion go green on its own — once that index is built pre-normalize.
+ if (element.Attribute("data-source-anchor-id") is { } sourceAnchor)
+ {
+ var value = sourceAnchor.Value;
+ var kindEnd = value.IndexOf(':');
+ if (kindEnd >= 0) sourceAnchor.Value = value.Substring(kindEnd + 1);
+ }
+
+ var attributes = element.Attributes()
+ .Select(attribute => new System.Xml.Linq.XAttribute(attribute))
+ .OrderBy(attribute => attribute.Name.NamespaceName, StringComparer.Ordinal)
+ .ThenBy(attribute => attribute.Name.LocalName, StringComparer.Ordinal)
+ .ToList();
+ element.ReplaceAttributes(attributes);
+ }
+ return clone;
+ }
+
// Regression: rendering a block AFTER a structural edit added a paragraph used to
// throw "should never set ilvl more than once" — re-initializing ListItemRetriever
// over a partially annotated live document was not idempotent, and the editor's
diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs
index 674fe487..a3709e58 100644
--- a/Docxodus.Tests/McpServerDispatcherTests.cs
+++ b/Docxodus.Tests/McpServerDispatcherTests.cs
@@ -236,6 +236,56 @@ public void MCP010_GetContent_AllFormats_Succeed()
}
}
+ [Fact]
+ public void MCP011_GetContent_IntrospectionFormats_ReturnMutationCompatibleIdsAndSpans()
+ {
+ var sessionId = OpenSession();
+ var sessionArg = JsonSerializer.Serialize(sessionId);
+ var anchor = FirstBodyAnchorId(sessionId, _store);
+ Assert.True(ReplaceText(_store, sessionId, anchor, "Alpha beta")
+ .GetProperty("success").GetBoolean());
+
+ var styles = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
+ $$"""{"sessionId":{{sessionArg}},"format":"styles"}""")))
+ .GetProperty("styles").EnumerateArray().ToArray();
+ var paragraphStyle = styles.First(s => s.GetProperty("type").GetString() == "paragraph");
+ var styleId = paragraphStyle.GetProperty("id").GetString()!;
+ var styleMutation = Parse(Dispatcher.Call(_store, "docxodus_format", J(
+ $$"""{"sessionId":{{sessionArg}},"action":"set_paragraph_style","anchorId":"{{anchor}}","styleId":{{JsonSerializer.Serialize(styleId)}}}""")));
+ Assert.True(styleMutation.GetProperty("success").GetBoolean());
+
+ var formatting = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
+ $$"""{"sessionId":{{sessionArg}},"format":"formatting","anchorId":"{{anchor}}"}""")))
+ .GetProperty("formatting");
+ Assert.Equal(anchor, formatting.GetProperty("anchorId").GetString());
+ Assert.Equal(JsonValueKind.Object, formatting.GetProperty("directParagraph").ValueKind);
+ Assert.Equal(JsonValueKind.Object, formatting.GetProperty("effectiveParagraph").ValueKind);
+
+ var span = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
+ $$"""{"sessionId":{{sessionArg}},"format":"spans","anchorId":"{{anchor}}"}""")))
+ .GetProperty("spans")[0];
+ var spanAnchor = span.GetProperty("anchorId").GetString()!;
+ var range = span.GetProperty("span");
+ var spanArgs = JsonSerializer.Serialize(new
+ {
+ sessionId,
+ action = "apply_format",
+ anchorId = spanAnchor,
+ span = new
+ {
+ start = range.GetProperty("start").GetInt32(),
+ length = range.GetProperty("length").GetInt32(),
+ },
+ format = new { bold = true },
+ });
+ var spanMutation = Parse(Dispatcher.Call(_store, "docxodus_format", J(spanArgs)));
+ Assert.True(spanMutation.GetProperty("success").GetBoolean());
+
+ var info = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
+ $$"""{"sessionId":{{sessionArg}},"format":"info","anchorId":"{{anchor}}"}""")));
+ Assert.Equal(anchor, info.GetProperty("sectionInfo").GetProperty("anchorId").GetString());
+ }
+
// ─── Edit ───────────────────────────────────────────────────────────
[Fact]
diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs
index 1c55a8bd..bff97922 100644
--- a/Docxodus/DocxSession.cs
+++ b/Docxodus/DocxSession.cs
@@ -371,6 +371,128 @@ public sealed record RunFormatting
public string? RunStyle { get; init; }
}
+///
+/// High-signal paragraph properties used by the formatting-inspection surface. Every property is
+/// nullable so a direct snapshot can distinguish "not written here" from an explicit zero
+/// or false. Effective snapshots fill the schema defaults for alignment, indentation, spacing,
+/// line spacing, and on/off properties after applying document defaults and the paragraph-style
+/// chain through .
+///
+public sealed record ParagraphFormatting
+{
+ /// The paragraph style id in effect at this layer. This is accepted directly by
+ /// .
+ public string? StyleId { get; init; }
+ public ParagraphAlignment? Alignment { get; init; }
+ public int? LeftIndentTwips { get; init; }
+ public int? RightIndentTwips { get; init; }
+ public int? FirstLineIndentTwips { get; init; }
+ public int? HangingIndentTwips { get; init; }
+ public int? SpacingBeforeTwips { get; init; }
+ public int? SpacingAfterTwips { get; init; }
+ public int? LineSpacing { get; init; }
+ public LineSpacingRule? LineSpacingRule { get; init; }
+ public bool? KeepNext { get; init; }
+ public bool? KeepLines { get; init; }
+ public bool? PageBreakBefore { get; init; }
+ public int? OutlineLevel { get; init; }
+ public string? ShadingFill { get; init; }
+ public ParagraphBorderEdge? TopBorder { get; init; }
+ public ParagraphBorderEdge? BottomBorder { get; init; }
+}
+
+///
+/// High-signal run properties used by style, anchor, and inline-span introspection. Nullable
+/// fields preserve the difference between an absent direct property and an explicit off value;
+/// effective snapshots resolve the document/style cascade and fill false for absent toggles.
+///
+public sealed record RunFormattingInfo
+{
+ /// Character-style id at this layer. Accepted directly by
+ /// .
+ public string? StyleId { get; init; }
+ public bool? Bold { get; init; }
+ public bool? Italic { get; init; }
+ public bool? Underline { get; init; }
+ public string? UnderlineStyle { get; init; }
+ public bool? Strike { get; init; }
+ public bool? Code { get; init; }
+ public string? Color { get; init; }
+ public string? Highlight { get; init; }
+ public string? VertAlign { get; init; }
+ public double? FontSizePts { get; init; }
+ public string? FontFamily { get; init; }
+ public bool? Caps { get; init; }
+ public bool? SmallCaps { get; init; }
+ public bool? Hidden { get; init; }
+}
+
+/// High-signal base properties for a table style. These describe the style definition,
+/// not the geometry or formatting of any concrete table (owned by issue #450).
+public sealed record TableStyleFormatting
+{
+ public string? Alignment { get; init; }
+ public int? WidthTwips { get; init; }
+ public int? IndentTwips { get; init; }
+ public string? Layout { get; init; }
+ public bool? HasBorders { get; init; }
+ public string? CellShadingFill { get; init; }
+}
+
+/// One explicit style definition from the document's style catalog.
+public sealed record StyleInfo
+{
+ /// Stable w:styleId, accepted by paragraph/run style mutation tools.
+ required public string Id { get; init; }
+ required public string Name { get; init; }
+
+ /// OOXML style type (paragraph, character, table, or
+ /// numbering).
+ required public string Type { get; init; }
+ public string? BasedOn { get; init; }
+ public string? Next { get; init; }
+ public bool IsDefault { get; init; }
+ public bool IsCustom { get; init; }
+
+ /// True when w:latentStyles has an exception for this style name. The following
+ /// gallery fields resolve explicit style metadata over the exception and latent defaults.
+ public bool HasLatentException { get; init; }
+ public int? UiPriority { get; init; }
+ public bool? SemiHidden { get; init; }
+ public bool? UnhideWhenUsed { get; init; }
+ public bool? QuickFormat { get; init; }
+ public bool? Locked { get; init; }
+
+ public ParagraphFormatting? ResolvedParagraph { get; init; }
+ public RunFormattingInfo? ResolvedRun { get; init; }
+ public TableStyleFormatting? ResolvedTable { get; init; }
+}
+
+///
+/// One text-bearing run inside a paragraph-like anchor. plus
+/// can be passed directly to ; the run
+/// Unid is also reported for stable correlation but is not a separate mutation handle.
+///
+public sealed record InlineSpan
+{
+ required public string AnchorId { get; init; }
+ required public string RunUnid { get; init; }
+ required public CharSpan Span { get; init; }
+ required public string Text { get; init; }
+ required public RunFormattingInfo Direct { get; init; }
+ required public RunFormattingInfo Effective { get; init; }
+}
+
+/// Direct and effective formatting for one paragraph-like anchor.
+public sealed record FormattingInspection
+{
+ /// Stable anchor id accepted by paragraph and inline formatting mutation tools.
+ required public string AnchorId { get; init; }
+ required public ParagraphFormatting DirectParagraph { get; init; }
+ required public ParagraphFormatting EffectiveParagraph { get; init; }
+ required public IReadOnlyList Runs { get; init; }
+}
+
///
/// One piece of a that came from a single <w:r> run.
/// The uniquely identifies the run within the document; callers
@@ -779,6 +901,9 @@ public enum NumberFormat
///
public sealed record ListMembership
{
+ /// The stable paragraph/list-item anchor accepted by list mutation tools.
+ required public string AnchorId { get; init; }
+
/// The w:numId the paragraph belongs to (the w:num instance).
required public int NumId { get; init; }
@@ -795,6 +920,19 @@ public sealed record ListMembership
/// w:lvlOverride/w:startOverride, if any. null when no override is in effect.
public int? StartOverride { get; init; }
+ /// The level definition's w:start value. Defaults to 1 when omitted.
+ required public int Start { get; init; }
+
+ /// The level's marker template (w:lvlText), e.g. "%1." or
+ /// "(%2)".
+ public string? LevelText { get; init; }
+
+ /// Numbering-level indentation from w:lvl/w:pPr/w:ind.
+ public int? LeftIndentTwips { get; init; }
+ public int? RightIndentTwips { get; init; }
+ public int? FirstLineIndentTwips { get; init; }
+ public int? HangingIndentTwips { get; init; }
+
/// Always true for a paragraph carrying w:numPr (inline or via style).
required public bool IsAutoNumbered { get; init; }
@@ -876,6 +1014,11 @@ public sealed record HeaderFooterRef
///
public sealed record SectionInfo
{
+ /// The body anchor used for this query. It is stable within the session and accepted
+ /// directly by section mutation tools such as and
+ /// .
+ required public string AnchorId { get; init; }
+
/// The Unid of the w:sectPr element this info describes. Stable across mutations.
required public string SectionUnid { get; init; }
@@ -3496,6 +3639,49 @@ private static TableCellResolutionResult CellResolutionFail(
return target is null ? null : Internal.BlockMetadataOps.GetSectionInfo(_doc!, target);
}
+ ///
+ /// Enumerates the document's explicit paragraph, character, table, and numbering style
+ /// definitions in declaration order. Each entry includes inheritance/gallery metadata and
+ /// high-signal effective properties; returned style ids are the ids accepted by the matching
+ /// paragraph/run style mutation fields.
+ ///
+ public IReadOnlyList ListStyles()
+ {
+ ThrowIfDisposed();
+ return Internal.FormattingIntrospectionOps.ListStyles(_doc!);
+ }
+
+ ///
+ /// Inspect direct and effective paragraph formatting plus every text-bearing run's direct and
+ /// effective formatting for a paragraph/heading/list-item anchor. Returns null for an
+ /// unknown or non-paragraph anchor.
+ ///
+ public FormattingInspection? GetFormatting(string anchorId)
+ {
+ ThrowIfDisposed();
+ ArgumentNullException.ThrowIfNull(anchorId);
+ var target = FindAnchor(anchorId);
+ return target is null
+ ? null
+ : Internal.FormattingIntrospectionOps.GetFormatting(_doc!, target);
+ }
+
+ ///
+ /// Enumerate text-bearing inline runs for a paragraph/heading/list-item anchor. Every result
+ /// carries a block-relative that can be passed directly to
+ /// . Unknown/non-paragraph anchors return
+ /// an empty list.
+ ///
+ public IReadOnlyList ListInlineSpans(string anchorId)
+ {
+ ThrowIfDisposed();
+ ArgumentNullException.ThrowIfNull(anchorId);
+ var target = FindAnchor(anchorId);
+ return target is null
+ ? Array.Empty()
+ : Internal.FormattingIntrospectionOps.ListInlineSpans(_doc!, target);
+ }
+
///
/// Searches the flat text of every paragraph/heading/list-item in
/// for matches of and returns them in document order, each
diff --git a/Docxodus/FormattingAssembler.cs b/Docxodus/FormattingAssembler.cs
index adce379a..e8b6ba13 100644
--- a/Docxodus/FormattingAssembler.cs
+++ b/Docxodus/FormattingAssembler.cs
@@ -2213,8 +2213,13 @@ private static IEnumerable TableStyleStack(WordprocessingDocument wDoc
{
XDocument sXDoc = wDoc.MainDocumentPart.StyleDefinitionsPart.GetXDocument();
string currentStyle = tblStyleName;
+ // Cycle guard: see ParaStyleParaPropsStack. A self- or mutually-basedOn table style
+ // terminates here instead of looping forever.
+ var visitedTableStyles = new HashSet(StringComparer.Ordinal);
while (true)
{
+ if (currentStyle == null || !visitedTableStyles.Add(currentStyle))
+ yield break;
XElement style = sXDoc
.Root
.Elements(W.style).Where(s => (string)s.Attribute(W.type) == "table" &&
@@ -2453,6 +2458,133 @@ public static XElement ParagraphStyleRollup(XElement paragraph, XDocument styles
return ParagraphStyleRollupInternal(paragraph, stylesXDoc, defaultParagraphStyleName, null);
}
+ ///
+ /// Non-mutating paragraph-property resolver for anchor introspection. Cascades document
+ /// defaults, the paragraph style chain (through ), and
+ /// the paragraph's direct w:pPr, returning a detached effective w:pPr.
+ /// Deliberately EXCLUDES the numbering-level and table-style pPr layers the render
+ /// path applies in AssembleParagraphProperties — see the "effective formatting
+ /// cascade" limits in docs/architecture/docx_mutation_api.md.
+ ///
+ internal static XElement ResolveEffectiveParagraphProperties(
+ WordprocessingDocument wDoc, XElement paragraph)
+ {
+ var stylesPart = wDoc.MainDocumentPart?.StyleDefinitionsPart;
+ var direct = paragraph.Element(W.pPr) ?? new XElement(W.pPr);
+ if (stylesPart == null)
+ return new XElement(direct);
+
+ var stylesXDoc = stylesPart.GetXDocument();
+ var defaultParagraphStyleName = (string)stylesXDoc.Root?
+ .Elements(W.style)
+ .FirstOrDefault(s => (string)s.Attribute(W.type) == "paragraph"
+ && s.Attribute(W._default).ToBoolean() == true)?
+ .Attribute(W.styleId);
+ var defaults = stylesXDoc.Root?
+ .Element(W.docDefaults)?
+ .Element(W.pPrDefault)?
+ .Element(W.pPr) ?? new XElement(W.pPr);
+
+ // ParagraphStyleRollup folds an extra numbering-level pPr layer in whenever the
+ // paragraph carries ListItemRetriever's ListItemInfo annotation. Those annotations
+ // are a lazily built CACHE: whether a live document holds them depends only on
+ // whether something rendered, projected, or resolved a list label first. Reading
+ // them from a pure read API would make the same unmutated document answer this
+ // query two different ways depending on call order. Resolve against an
+ // annotation-free probe (a clone drops annotations) so "effective" is a function of
+ // the document alone — at the cost of pinning the shorter cascade documented above.
+ var probe = new XElement(W.p, paragraph.Attributes(), paragraph.Element(W.pPr));
+ var styleRollup = ParagraphStyleRollup(
+ probe, stylesXDoc, defaultParagraphStyleName);
+ var inherited = MergeStyleElement(styleRollup, defaults);
+ return new XElement(MergeStyleElement(direct, inherited));
+ }
+
+ ///
+ /// Non-mutating run-property resolver for anchor introspection. Reuses the assembler's
+ /// paragraph/character style rollup, toggle handling, document defaults, and theme-font
+ /// resolution, then overlays the run's direct w:rPr. Returns a detached effective
+ /// w:rPr; the source run and package are not changed.
+ ///
+ internal static XElement ResolveEffectiveRunProperties(
+ WordprocessingDocument wDoc, XElement run)
+ {
+ var stylesPart = wDoc.MainDocumentPart?.StyleDefinitionsPart;
+ var direct = run.Element(W.rPr) ?? new XElement(W.rPr);
+ if (stylesPart == null)
+ {
+ var stockDefaults = new XElement(W.rPr,
+ new XElement(W.rFonts,
+ new XAttribute(W.ascii, "Times New Roman"),
+ new XAttribute(W.hAnsi, "Times New Roman"),
+ new XAttribute(W.cs, "Times New Roman")),
+ new XElement(W.sz, new XAttribute(W.val, "20")),
+ new XElement(W.szCs, new XAttribute(W.val, "20")));
+ return new XElement(MergeStyleElement(direct, stockDefaults));
+ }
+
+ var stylesXDoc = stylesPart.GetXDocument();
+ var fai = new FormattingAssemblerInfo();
+ IndexStylesDocument(stylesXDoc, fai);
+ foreach (var style in stylesXDoc.Root.Elements(W.style))
+ {
+ if (style.Attribute(W._default).ToBoolean() != true)
+ continue;
+ var styleType = (string)style.Attribute(W.type);
+ var styleId = (string)style.Attribute(W.styleId);
+ if (styleType == "paragraph") fai.DefaultParagraphStyleName = styleId;
+ else if (styleType == "character") fai.DefaultCharacterStyleName = styleId;
+ else if (styleType == "table") fai.DefaultTableStyleName = styleId;
+ }
+
+ var defaults = stylesXDoc.Root?
+ .Element(W.docDefaults)?
+ .Element(W.rPrDefault)?
+ .Element(W.rPr);
+ defaults = defaults == null
+ ? new XElement(W.rPr,
+ new XElement(W.rFonts,
+ new XAttribute(W.ascii, "Times New Roman"),
+ new XAttribute(W.hAnsi, "Times New Roman"),
+ new XAttribute(W.cs, "Times New Roman")),
+ new XElement(W.sz, new XAttribute(W.val, "20")),
+ new XElement(W.szCs, new XAttribute(W.val, "20")))
+ : new XElement(defaults);
+
+ // Match AnnotateWithGlobalDefaults: absent font/size values receive the stock Word
+ // defaults before the style and direct layers are applied.
+ if (defaults.Element(W.rFonts) == null)
+ defaults.Add(new XElement(W.rFonts,
+ new XAttribute(W.ascii, "Times New Roman"),
+ new XAttribute(W.hAnsi, "Times New Roman"),
+ new XAttribute(W.cs, "Times New Roman")));
+ if (defaults.Element(W.sz) == null)
+ defaults.Add(new XElement(W.sz, new XAttribute(W.val, "20")));
+ if (defaults.Element(W.szCs) == null)
+ defaults.Add(new XElement(W.szCs, new XAttribute(W.val, "20")));
+
+ var styleRollup = CharStyleRollup(fai, wDoc, run);
+ var inherited = MergeStyleElement(styleRollup, defaults);
+ var effective = new XElement(MergeStyleElement(direct, inherited));
+
+ // AdjustFontAttributes also annotates the supplied run with presentation hints. Use
+ // a detached probe so this read API remains non-mutating while retaining its existing
+ // theme-font resolver.
+ var probe = new XElement(run);
+ var paragraph = run.Ancestors(W.p).FirstOrDefault();
+ var effectivePPr = paragraph == null
+ ? null
+ : ResolveEffectiveParagraphProperties(wDoc, paragraph);
+ AdjustFontAttributes(wDoc, probe, effectivePPr, effective,
+ new FormattingAssemblerSettings());
+ return effective;
+ }
+
+ /// Resolve a table style's based-on chain to a detached style element. Used only
+ /// for catalog metadata; it does not inspect a concrete table's geometry or cell state.
+ internal static XElement ResolveTableStyle(WordprocessingDocument wDoc, string styleId) =>
+ new XElement(TableStyleRollup(wDoc, styleId));
+
///
/// Rolls up paragraph style properties from the style hierarchy.
/// Optimization #2: Caches results for non-list-item paragraphs by style name.
@@ -2510,8 +2642,16 @@ private static IEnumerable ParaStyleParaPropsStack(XDocument stylesXDo
if (stylesXDoc == null)
yield break;
var localParaStyleName = paraStyleName;
+ // w:basedOn is caller-supplied data, not a guaranteed tree: a document declaring
+ // A basedOn A (or A -> B -> A) would otherwise spin here forever. Terminate on the
+ // repeat and yield what accumulated — a cyclic style is a broken document, not a
+ // reason for a read API over uploaded files to hang.
+ var visitedParaStyles = new HashSet(StringComparer.Ordinal);
while (localParaStyleName != null)
{
+ if (!visitedParaStyles.Add(localParaStyleName))
+ yield break;
+
// Optimization #1: Use indexed lookup if available, otherwise fall back to linear search
XElement paraStyle;
if (fai != null && fai.ParagraphStyleIndex.TryGetValue(localParaStyleName, out paraStyle))
@@ -2846,8 +2986,13 @@ private static IEnumerable ParaStyleRunPropsStack(WordprocessingDocume
var localParaStyleName = paraStyleName;
var sXDoc = wDoc.MainDocumentPart.StyleDefinitionsPart.GetXDocument();
var rValue = new Stack();
+ // Cycle guard: see ParaStyleParaPropsStack.
+ var visitedParaStyles = new HashSet(StringComparer.Ordinal);
while (localParaStyleName != null)
{
+ if (!visitedParaStyles.Add(localParaStyleName))
+ return rValue;
+
// Optimization #1: Use indexed lookup if available, otherwise fall back to linear search
XElement paraStyle;
if (fai != null && fai.ParagraphStyleIndex.TryGetValue(localParaStyleName, out paraStyle))
@@ -2889,8 +3034,13 @@ private static IEnumerable CharStyleStack(WordprocessingDocument wDoc,
var localCharStyleName = charStyleName;
var sXDoc = wDoc.MainDocumentPart.StyleDefinitionsPart.GetXDocument();
var rValue = new Stack();
+ // Cycle guard: see ParaStyleParaPropsStack.
+ var visitedCharStyles = new HashSet(StringComparer.Ordinal);
while (localCharStyleName != null)
{
+ if (!visitedCharStyles.Add(localCharStyleName))
+ return rValue;
+
XElement basedOn = null;
XElement charStyle = null;
diff --git a/Docxodus/Internal/BlockMetadataOps.cs b/Docxodus/Internal/BlockMetadataOps.cs
index f919aadd..6be72875 100644
--- a/Docxodus/Internal/BlockMetadataOps.cs
+++ b/Docxodus/Internal/BlockMetadataOps.cs
@@ -94,14 +94,13 @@ internal static class BlockMetadataOps
? NumberFormats.ParseOoxml(fmtToken)
: null;
- // The sectPr itself doesn't carry a stable Unid in every fixture; fall back
- // to a deterministic synthetic id derived from element position so the field
- // is always non-null and stable across reads of the same doc state.
- var sectionUnid = (string?)sectPr.Attribute(PtOpenXml.Unid)
- ?? $"sect:{sectPr.Parent?.Elements().ToList().IndexOf(sectPr) ?? 0}";
+ // The anchor-index walk normally assigned this already. For unusual/custom projection
+ // scopes, derive the same deterministic identity without mutating the live package.
+ var sectionUnid = UnidHelper.ReadOrDeriveUnid(sectPr);
return new SectionInfo
{
+ AnchorId = target.Anchor.Id,
SectionUnid = sectionUnid,
PageWidthTwips = width,
PageHeightTwips = height,
@@ -320,31 +319,47 @@ private static (IReadOnlyList headers, IReadOnlyList (string?)a.Attribute(W.abstractNumId) == abstractNumId.ToString(System.Globalization.CultureInfo.InvariantCulture));
if (abstractNumEl is null) return null;
- var lvlEl = abstractNumEl.Elements(W.lvl)
+ var abstractLvlEl = abstractNumEl.Elements(W.lvl)
.FirstOrDefault(l => (string?)l.Attribute(W.ilvl) == level.ToString(System.Globalization.CultureInfo.InvariantCulture));
- var format = ParseNumberFormat((string?)lvlEl?.Element(W.numFmt)?.Attribute(W.val));
- // Start override from the w:num's lvlOverride for this level.
- int? startOverride = null;
+ // A concrete w:num can override either just the start value or the complete level. The
+ // complete-level form supplies effective format/text/indentation; omitted pieces continue
+ // to come from the abstract level.
var lvlOverrideEl = numEl.Elements(W.lvlOverride)
.FirstOrDefault(o => (string?)o.Attribute(W.ilvl) == level.ToString(System.Globalization.CultureInfo.InvariantCulture));
- if (lvlOverrideEl is not null)
- {
- var startOverrideEl = lvlOverrideEl.Element(W.startOverride);
- if (startOverrideEl is not null && int.TryParse((string?)startOverrideEl.Attribute(W.val), out var so))
- startOverride = so;
- }
+ var overrideLvlEl = lvlOverrideEl?.Element(W.lvl);
+ var format = ParseNumberFormat(
+ (string?)overrideLvlEl?.Element(W.numFmt)?.Attribute(W.val)
+ ?? (string?)abstractLvlEl?.Element(W.numFmt)?.Attribute(W.val));
+ var start = ParseInt(
+ (string?)overrideLvlEl?.Element(W.start)?.Attribute(W.val)
+ ?? (string?)abstractLvlEl?.Element(W.start)?.Attribute(W.val)) ?? 1;
+ var startOverride = ParseInt((string?)lvlOverrideEl?.Element(W.startOverride)?.Attribute(W.val));
+ var levelText = (string?)overrideLvlEl?.Element(W.lvlText)?.Attribute(W.val)
+ ?? (string?)abstractLvlEl?.Element(W.lvlText)?.Attribute(W.val);
+ var abstractInd = abstractLvlEl?.Element(W.pPr)?.Element(W.ind);
+ var overrideInd = overrideLvlEl?.Element(W.pPr)?.Element(W.ind);
+ int? Indent(XName name, XName alternate) =>
+ ParseInt((string?)overrideInd?.Attribute(name) ?? (string?)overrideInd?.Attribute(alternate)
+ ?? (string?)abstractInd?.Attribute(name) ?? (string?)abstractInd?.Attribute(alternate));
return new ListMembership
{
+ AnchorId = target.Anchor.Id,
NumId = numId,
AbstractNumId = abstractNumId,
Level = level,
Format = format,
StartOverride = startOverride,
+ Start = start,
+ LevelText = levelText,
+ LeftIndentTwips = Indent(W.left, W.start),
+ RightIndentTwips = Indent(W.right, W.end),
+ FirstLineIndentTwips = Indent(W.firstLine, W.firstLine),
+ HangingIndentTwips = Indent(W.hanging, W.hanging),
IsAutoNumbered = true,
FromStyle = fromStyle,
- GeneratedLabel = target.AutoNumberPrefix,
+ GeneratedLabel = target.AutoNumberPrefix ?? ListNumberResolver.Resolve(element, doc),
};
}
diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs
index e7439404..0460f662 100644
--- a/Docxodus/Internal/DocxSessionJson.cs
+++ b/Docxodus/Internal/DocxSessionJson.cs
@@ -1669,15 +1669,27 @@ public static string SerializeBlockMetadataMap(System.Collections.Generic.IReadO
public static string SerializeListMembershipOrNull(ListMembership? list)
{
if (list is null) return "null";
- var sb = new StringBuilder(128);
- sb.Append("{\"numId\":").Append(list.NumId)
+ var sb = new StringBuilder(256);
+ sb.Append("{\"anchorId\":").Append(JsonString(list.AnchorId))
+ .Append(",\"numId\":").Append(list.NumId)
.Append(",\"abstractNumId\":").Append(list.AbstractNumId)
.Append(",\"level\":").Append(list.Level)
.Append(",\"format\":").Append(JsonString(NumberFormatToString(list.Format)))
+ .Append(",\"start\":").Append(list.Start)
.Append(",\"isAutoNumbered\":").Append(list.IsAutoNumbered ? "true" : "false")
.Append(",\"fromStyle\":").Append(list.FromStyle ? "true" : "false");
if (list.StartOverride.HasValue)
sb.Append(",\"startOverride\":").Append(list.StartOverride.Value);
+ if (list.LevelText is not null)
+ sb.Append(",\"levelText\":").Append(JsonString(list.LevelText));
+ if (list.LeftIndentTwips.HasValue)
+ sb.Append(",\"leftIndentTwips\":").Append(list.LeftIndentTwips.Value);
+ if (list.RightIndentTwips.HasValue)
+ sb.Append(",\"rightIndentTwips\":").Append(list.RightIndentTwips.Value);
+ if (list.FirstLineIndentTwips.HasValue)
+ sb.Append(",\"firstLineIndentTwips\":").Append(list.FirstLineIndentTwips.Value);
+ if (list.HangingIndentTwips.HasValue)
+ sb.Append(",\"hangingIndentTwips\":").Append(list.HangingIndentTwips.Value);
if (list.GeneratedLabel is not null)
sb.Append(",\"generatedLabel\":").Append(JsonString(list.GeneratedLabel));
sb.Append('}');
@@ -1688,7 +1700,8 @@ public static string SerializeSectionInfoOrNull(SectionInfo? info)
{
if (info is null) return "null";
var sb = new StringBuilder(256);
- sb.Append("{\"sectionUnid\":").Append(JsonString(info.SectionUnid))
+ sb.Append("{\"anchorId\":").Append(JsonString(info.AnchorId))
+ .Append(",\"sectionUnid\":").Append(JsonString(info.SectionUnid))
.Append(",\"pageWidthTwips\":").Append(info.PageWidthTwips)
.Append(",\"pageHeightTwips\":").Append(info.PageHeightTwips)
.Append(",\"landscape\":").Append(info.Landscape ? "true" : "false")
@@ -1722,6 +1735,262 @@ public static string SerializeSectionInfoOrNull(SectionInfo? info)
return sb.ToString();
}
+ public static string SerializeStyles(IReadOnlyList styles)
+ {
+ var sb = new StringBuilder(styles.Count * 400 + 2);
+ sb.Append('[');
+ for (int i = 0; i < styles.Count; i++)
+ {
+ if (i > 0) sb.Append(',');
+ var style = styles[i];
+ sb.Append("{\"id\":").Append(JsonString(style.Id))
+ .Append(",\"name\":").Append(JsonString(style.Name))
+ .Append(",\"type\":").Append(JsonString(style.Type));
+ if (style.BasedOn is not null)
+ sb.Append(",\"basedOn\":").Append(JsonString(style.BasedOn));
+ if (style.Next is not null)
+ sb.Append(",\"next\":").Append(JsonString(style.Next));
+ sb.Append(",\"isDefault\":").Append(style.IsDefault ? "true" : "false")
+ .Append(",\"isCustom\":").Append(style.IsCustom ? "true" : "false")
+ .Append(",\"hasLatentException\":").Append(style.HasLatentException ? "true" : "false");
+ if (style.UiPriority.HasValue)
+ sb.Append(",\"uiPriority\":").Append(style.UiPriority.Value);
+ AppendNullableBool(sb, "semiHidden", style.SemiHidden);
+ AppendNullableBool(sb, "unhideWhenUsed", style.UnhideWhenUsed);
+ AppendNullableBool(sb, "quickFormat", style.QuickFormat);
+ AppendNullableBool(sb, "locked", style.Locked);
+ if (style.ResolvedParagraph is not null)
+ {
+ sb.Append(",\"resolvedParagraph\":");
+ AppendParagraphFormatting(sb, style.ResolvedParagraph);
+ }
+ if (style.ResolvedRun is not null)
+ {
+ sb.Append(",\"resolvedRun\":");
+ AppendRunFormattingInfo(sb, style.ResolvedRun);
+ }
+ if (style.ResolvedTable is not null)
+ {
+ sb.Append(",\"resolvedTable\":");
+ AppendTableStyleFormatting(sb, style.ResolvedTable);
+ }
+ sb.Append('}');
+ }
+ sb.Append(']');
+ return sb.ToString();
+ }
+
+ public static string SerializeFormattingInspectionOrNull(FormattingInspection? inspection)
+ {
+ if (inspection is null) return "null";
+ var sb = new StringBuilder(512 + inspection.Runs.Count * 300);
+ sb.Append("{\"anchorId\":").Append(JsonString(inspection.AnchorId))
+ .Append(",\"directParagraph\":");
+ AppendParagraphFormatting(sb, inspection.DirectParagraph);
+ sb.Append(",\"effectiveParagraph\":");
+ AppendParagraphFormatting(sb, inspection.EffectiveParagraph);
+ sb.Append(",\"runs\":");
+ AppendInlineSpans(sb, inspection.Runs);
+ sb.Append('}');
+ return sb.ToString();
+ }
+
+ public static string SerializeInlineSpans(IReadOnlyList spans)
+ {
+ var sb = new StringBuilder(spans.Count * 300 + 2);
+ AppendInlineSpans(sb, spans);
+ return sb.ToString();
+ }
+
+ private static void AppendInlineSpans(StringBuilder sb, IReadOnlyList spans)
+ {
+ sb.Append('[');
+ for (int i = 0; i < spans.Count; i++)
+ {
+ if (i > 0) sb.Append(',');
+ var span = spans[i];
+ sb.Append("{\"anchorId\":").Append(JsonString(span.AnchorId))
+ .Append(",\"runUnid\":").Append(JsonString(span.RunUnid))
+ .Append(",\"span\":{\"start\":").Append(span.Span.Start)
+ .Append(",\"length\":").Append(span.Span.Length).Append('}')
+ .Append(",\"text\":").Append(JsonString(span.Text))
+ .Append(",\"direct\":");
+ AppendRunFormattingInfo(sb, span.Direct);
+ sb.Append(",\"effective\":");
+ AppendRunFormattingInfo(sb, span.Effective);
+ sb.Append('}');
+ }
+ sb.Append(']');
+ }
+
+ private static void AppendParagraphFormatting(StringBuilder sb, ParagraphFormatting f)
+ {
+ sb.Append('{');
+ bool has = false;
+ void StringValue(string name, string? value)
+ {
+ if (value is null) return;
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append(JsonString(name)).Append(':').Append(JsonString(value));
+ }
+ void IntValue(string name, int? value)
+ {
+ if (!value.HasValue) return;
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append(JsonString(name)).Append(':').Append(value.Value);
+ }
+ void BoolValue(string name, bool? value)
+ {
+ if (!value.HasValue) return;
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append(JsonString(name)).Append(':').Append(value.Value ? "true" : "false");
+ }
+
+ StringValue("styleId", f.StyleId);
+ StringValue("alignment", f.Alignment switch
+ {
+ ParagraphAlignment.Center => "center",
+ ParagraphAlignment.Right => "right",
+ ParagraphAlignment.Justify => "justify",
+ ParagraphAlignment.Left => "left",
+ _ => null,
+ });
+ IntValue("leftIndentTwips", f.LeftIndentTwips);
+ IntValue("rightIndentTwips", f.RightIndentTwips);
+ IntValue("firstLineIndentTwips", f.FirstLineIndentTwips);
+ IntValue("hangingIndentTwips", f.HangingIndentTwips);
+ IntValue("spacingBeforeTwips", f.SpacingBeforeTwips);
+ IntValue("spacingAfterTwips", f.SpacingAfterTwips);
+ IntValue("lineSpacing", f.LineSpacing);
+ StringValue("lineSpacingRule", f.LineSpacingRule switch
+ {
+ LineSpacingRule.Exact => "exact",
+ LineSpacingRule.AtLeast => "atLeast",
+ LineSpacingRule.Auto => "auto",
+ _ => null,
+ });
+ BoolValue("keepNext", f.KeepNext);
+ BoolValue("keepLines", f.KeepLines);
+ BoolValue("pageBreakBefore", f.PageBreakBefore);
+ IntValue("outlineLevel", f.OutlineLevel);
+ StringValue("shadingFill", f.ShadingFill);
+ AppendBorder("topBorder", f.TopBorder);
+ AppendBorder("bottomBorder", f.BottomBorder);
+ sb.Append('}');
+
+ void AppendBorder(string name, ParagraphBorderEdge? edge)
+ {
+ if (edge is null) return;
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append(JsonString(name)).Append(":{");
+ bool edgeHas = false;
+ void EdgeString(string key, string? value)
+ {
+ if (value is null) return;
+ if (edgeHas) sb.Append(',');
+ edgeHas = true;
+ sb.Append(JsonString(key)).Append(':').Append(JsonString(value));
+ }
+ void EdgeInt(string key, int? value)
+ {
+ if (!value.HasValue) return;
+ if (edgeHas) sb.Append(',');
+ edgeHas = true;
+ sb.Append(JsonString(key)).Append(':').Append(value.Value);
+ }
+ EdgeString("style", edge.Style);
+ EdgeInt("size", edge.Size);
+ EdgeString("color", edge.Color);
+ EdgeInt("space", edge.Space);
+ sb.Append('}');
+ }
+ }
+
+ private static void AppendRunFormattingInfo(StringBuilder sb, RunFormattingInfo f)
+ {
+ sb.Append('{');
+ bool has = false;
+ void StringValue(string name, string? value)
+ {
+ if (value is null) return;
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append(JsonString(name)).Append(':').Append(JsonString(value));
+ }
+ void BoolValue(string name, bool? value)
+ {
+ if (!value.HasValue) return;
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append(JsonString(name)).Append(':').Append(value.Value ? "true" : "false");
+ }
+ StringValue("styleId", f.StyleId);
+ BoolValue("bold", f.Bold);
+ BoolValue("italic", f.Italic);
+ BoolValue("underline", f.Underline);
+ StringValue("underlineStyle", f.UnderlineStyle);
+ BoolValue("strike", f.Strike);
+ BoolValue("code", f.Code);
+ StringValue("color", f.Color);
+ StringValue("highlight", f.Highlight);
+ StringValue("vertAlign", f.VertAlign);
+ if (f.FontSizePts.HasValue)
+ {
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append("\"fontSizePts\":").Append(f.FontSizePts.Value.ToString(
+ System.Globalization.CultureInfo.InvariantCulture));
+ }
+ StringValue("fontFamily", f.FontFamily);
+ BoolValue("caps", f.Caps);
+ BoolValue("smallCaps", f.SmallCaps);
+ BoolValue("hidden", f.Hidden);
+ sb.Append('}');
+ }
+
+ private static void AppendTableStyleFormatting(StringBuilder sb, TableStyleFormatting f)
+ {
+ sb.Append('{');
+ bool has = false;
+ void StringValue(string name, string? value)
+ {
+ if (value is null) return;
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append(JsonString(name)).Append(':').Append(JsonString(value));
+ }
+ void IntValue(string name, int? value)
+ {
+ if (!value.HasValue) return;
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append(JsonString(name)).Append(':').Append(value.Value);
+ }
+ StringValue("alignment", f.Alignment);
+ IntValue("widthTwips", f.WidthTwips);
+ IntValue("indentTwips", f.IndentTwips);
+ StringValue("layout", f.Layout);
+ if (f.HasBorders.HasValue)
+ {
+ if (has) sb.Append(',');
+ has = true;
+ sb.Append("\"hasBorders\":").Append(f.HasBorders.Value ? "true" : "false");
+ }
+ StringValue("cellShadingFill", f.CellShadingFill);
+ sb.Append('}');
+ }
+
+ private static void AppendNullableBool(StringBuilder sb, string name, bool? value)
+ {
+ if (!value.HasValue) return;
+ sb.Append(',').Append(JsonString(name)).Append(':')
+ .Append(value.Value ? "true" : "false");
+ }
+
private static void AppendHeaderFooterRefs(
StringBuilder sb, string key, IReadOnlyList refs)
{
diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs
index 8e306b4e..b8f2df96 100644
--- a/Docxodus/Internal/DocxSessionOps.cs
+++ b/Docxodus/Internal/DocxSessionOps.cs
@@ -295,6 +295,15 @@ public static string GetListMembership(int handle, string anchorId) =>
public static string GetSectionInfo(int handle, string anchorId) =>
DocxSessionJson.SerializeSectionInfoOrNull(SessionRegistry.Get(handle).GetSectionInfo(anchorId));
+ public static string ListStyles(int handle) =>
+ DocxSessionJson.SerializeStyles(SessionRegistry.Get(handle).ListStyles());
+
+ public static string GetFormatting(int handle, string anchorId) =>
+ DocxSessionJson.SerializeFormattingInspectionOrNull(SessionRegistry.Get(handle).GetFormatting(anchorId));
+
+ public static string ListInlineSpans(int handle, string anchorId) =>
+ DocxSessionJson.SerializeInlineSpans(SessionRegistry.Get(handle).ListInlineSpans(anchorId));
+
public static string FindByText(int handle, string needle, FindOptions? options) =>
DocxSessionJson.SerializeAnchorTargetOrNull(SessionRegistry.Get(handle).FindByText(needle, options));
diff --git a/Docxodus/Internal/FormattingIntrospectionOps.cs b/Docxodus/Internal/FormattingIntrospectionOps.cs
new file mode 100644
index 00000000..61030a07
--- /dev/null
+++ b/Docxodus/Internal/FormattingIntrospectionOps.cs
@@ -0,0 +1,364 @@
+#nullable enable
+
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Xml.Linq;
+using DocumentFormat.OpenXml.Packaging;
+
+namespace Docxodus.Internal;
+
+///
+/// Pure style and formatting resolvers for the session inspection surface. The effective paths
+/// deliberately route through 's style rollups so inspection and
+/// rendering do not grow independent inheritance implementations.
+///
+internal static class FormattingIntrospectionOps
+{
+ public static IReadOnlyList ListStyles(WordprocessingDocument doc)
+ {
+ var root = doc.MainDocumentPart?.StyleDefinitionsPart?.GetXDocument().Root;
+ if (root is null) return Array.Empty();
+
+ var latent = root.Element(W.latentStyles);
+ var result = new List();
+ foreach (var style in root.Elements(W.style))
+ {
+ var id = (string?)style.Attribute(W.styleId);
+ if (string.IsNullOrEmpty(id)) continue;
+ var name = (string?)style.Element(W.name)?.Attribute(W.val) ?? id;
+ var type = (string?)style.Attribute(W.type) ?? "paragraph";
+ var latentException = latent?.Elements(W.lsdException).FirstOrDefault(e =>
+ string.Equals((string?)e.Attribute(W.name), name, StringComparison.OrdinalIgnoreCase));
+
+ ParagraphFormatting? paragraph = null;
+ RunFormattingInfo? run = null;
+ TableStyleFormatting? table = null;
+
+ if (type == "paragraph")
+ {
+ var synthetic = SyntheticParagraph(id, characterStyleId: null);
+ paragraph = ParseParagraph(
+ FormattingAssembler.ResolveEffectiveParagraphProperties(doc, synthetic),
+ effective: true,
+ effectiveStyleId: id);
+ run = ParseRun(
+ FormattingAssembler.ResolveEffectiveRunProperties(doc, synthetic.Element(W.r)!),
+ effective: true,
+ effectiveStyleId: null);
+ }
+ else if (type == "character")
+ {
+ var synthetic = SyntheticParagraph(paragraphStyleId: null, characterStyleId: id);
+ run = ParseRun(
+ FormattingAssembler.ResolveEffectiveRunProperties(doc, synthetic.Element(W.r)!),
+ effective: true,
+ effectiveStyleId: id);
+ }
+ else if (type == "table")
+ {
+ table = ParseTableStyle(FormattingAssembler.ResolveTableStyle(doc, id));
+ }
+
+ result.Add(new StyleInfo
+ {
+ Id = id,
+ Name = name,
+ Type = type,
+ BasedOn = (string?)style.Element(W.basedOn)?.Attribute(W.val),
+ Next = (string?)style.Element(W.next)?.Attribute(W.val),
+ IsDefault = ReadOnOffAttribute(style.Attribute(W._default)) == true,
+ IsCustom = ReadOnOffAttribute(style.Attribute(W.customStyle)) == true,
+ HasLatentException = latentException is not null,
+ UiPriority = ReadIntAttribute(style.Element(W.uiPriority)?.Attribute(W.val))
+ ?? ReadIntAttribute(latentException?.Attribute(W.uiPriority))
+ ?? ReadIntAttribute(latent?.Attribute(W.defUIPriority)),
+ SemiHidden = ResolveGalleryBool(style, W.semiHidden,
+ latentException, W.semiHidden, latent, W.defSemiHidden),
+ UnhideWhenUsed = ResolveGalleryBool(style, W.unhideWhenUsed,
+ latentException, W.unhideWhenUsed, latent, W.defUnhideWhenUsed),
+ QuickFormat = ResolveGalleryBool(style, W.qFormat,
+ latentException, W.qFormat, latent, W.defQFormat),
+ Locked = ResolveGalleryBool(style, W.locked,
+ latentException, W.locked, latent, W.defLockedState),
+ ResolvedParagraph = paragraph,
+ ResolvedRun = run,
+ ResolvedTable = table,
+ });
+ }
+ return result;
+ }
+
+ public static FormattingInspection? GetFormatting(WordprocessingDocument doc, AnchorTarget target)
+ {
+ var paragraph = target.Resolve(doc);
+ if (paragraph is null || paragraph.Name != W.p) return null;
+
+ var direct = ParseParagraph(paragraph.Element(W.pPr), effective: false, effectiveStyleId: null);
+ var effectiveStyleId = (string?)paragraph.Element(W.pPr)?.Element(W.pStyle)?.Attribute(W.val)
+ ?? DefaultParagraphStyleId(doc);
+ var effective = ParseParagraph(
+ FormattingAssembler.ResolveEffectiveParagraphProperties(doc, paragraph),
+ effective: true,
+ effectiveStyleId);
+
+ return new FormattingInspection
+ {
+ AnchorId = target.Anchor.Id,
+ DirectParagraph = direct,
+ EffectiveParagraph = effective,
+ Runs = ListInlineSpans(doc, target),
+ };
+ }
+
+ public static IReadOnlyList ListInlineSpans(
+ WordprocessingDocument doc, AnchorTarget target)
+ {
+ var paragraph = target.Resolve(doc);
+ if (paragraph is null || paragraph.Name != W.p) return Array.Empty();
+
+ var map = RunTextMap.Build(paragraph);
+ var result = new List(map.Segments.Count);
+ foreach (var segment in map.Segments)
+ {
+ var run = segment.Run;
+ // The anchor index normally assigned this already. For unusual/custom projection
+ // scopes, derive the same deterministic identity without mutating the live package.
+ var unid = UnidHelper.ReadOrDeriveUnid(run);
+
+ var directStyleId = (string?)run.Element(W.rPr)?.Element(W.rStyle)?.Attribute(W.val);
+ result.Add(new InlineSpan
+ {
+ AnchorId = target.Anchor.Id,
+ RunUnid = unid,
+ Span = new CharSpan(segment.StartOffsetInBlock, segment.Length),
+ Text = DocxSession.RunText(run),
+ Direct = ParseRun(run.Element(W.rPr), effective: false, effectiveStyleId: null),
+ Effective = ParseRun(
+ FormattingAssembler.ResolveEffectiveRunProperties(doc, run),
+ effective: true,
+ effectiveStyleId: directStyleId),
+ });
+ }
+ return result;
+ }
+
+ private static XElement SyntheticParagraph(string? paragraphStyleId, string? characterStyleId)
+ {
+ var pPr = new XElement(W.pPr,
+ paragraphStyleId is null
+ ? null
+ : new XElement(W.pStyle, new XAttribute(W.val, paragraphStyleId)));
+ var rPr = new XElement(W.rPr,
+ characterStyleId is null
+ ? null
+ : new XElement(W.rStyle, new XAttribute(W.val, characterStyleId)));
+ return new XElement(W.p, pPr,
+ new XElement(W.r, rPr, new XElement(W.t, "x")));
+ }
+
+ private static string? DefaultParagraphStyleId(WordprocessingDocument doc) =>
+ (string?)doc.MainDocumentPart?.StyleDefinitionsPart?.GetXDocument().Root?
+ .Elements(W.style)
+ .FirstOrDefault(s => (string?)s.Attribute(W.type) == "paragraph"
+ && ReadOnOffAttribute(s.Attribute(W._default)) == true)?
+ .Attribute(W.styleId);
+
+ private static ParagraphFormatting ParseParagraph(
+ XElement? pPr, bool effective, string? effectiveStyleId)
+ {
+ var ind = pPr?.Element(W.ind);
+ var spacing = pPr?.Element(W.spacing);
+ var alignment = ParseAlignment((string?)pPr?.Element(W.jc)?.Attribute(W.val));
+ var line = ReadIntAttribute(spacing?.Attribute(W.line));
+ var lineRule = ParseLineSpacingRule((string?)spacing?.Attribute(W.lineRule));
+
+ var value = new ParagraphFormatting
+ {
+ StyleId = effectiveStyleId
+ ?? (string?)pPr?.Element(W.pStyle)?.Attribute(W.val),
+ Alignment = alignment,
+ LeftIndentTwips = ReadIntAttribute(ind?.Attribute(W.left) ?? ind?.Attribute(W.start)),
+ RightIndentTwips = ReadIntAttribute(ind?.Attribute(W.right) ?? ind?.Attribute(W.end)),
+ FirstLineIndentTwips = ReadIntAttribute(ind?.Attribute(W.firstLine)),
+ HangingIndentTwips = ReadIntAttribute(ind?.Attribute(W.hanging)),
+ SpacingBeforeTwips = ReadIntAttribute(spacing?.Attribute(W.before)),
+ SpacingAfterTwips = ReadIntAttribute(spacing?.Attribute(W.after)),
+ LineSpacing = line,
+ LineSpacingRule = line is null ? null : lineRule ?? LineSpacingRule.Auto,
+ KeepNext = ReadOnOffElement(pPr?.Element(W.keepNext)),
+ KeepLines = ReadOnOffElement(pPr?.Element(W.keepLines)),
+ PageBreakBefore = ReadOnOffElement(pPr?.Element(W.pageBreakBefore)),
+ OutlineLevel = ReadIntAttribute(pPr?.Element(W.outlineLvl)?.Attribute(W.val)),
+ ShadingFill = (string?)pPr?.Element(W.shd)?.Attribute(W.fill),
+ TopBorder = ParseBorder(pPr?.Element(W.pBdr)?.Element(W.top)),
+ BottomBorder = ParseBorder(pPr?.Element(W.pBdr)?.Element(W.bottom)),
+ };
+
+ if (!effective) return value;
+ return value with
+ {
+ Alignment = value.Alignment ?? ParagraphAlignment.Left,
+ LeftIndentTwips = value.LeftIndentTwips ?? 0,
+ RightIndentTwips = value.RightIndentTwips ?? 0,
+ FirstLineIndentTwips = value.FirstLineIndentTwips ?? 0,
+ HangingIndentTwips = value.HangingIndentTwips ?? 0,
+ SpacingBeforeTwips = value.SpacingBeforeTwips ?? 0,
+ SpacingAfterTwips = value.SpacingAfterTwips ?? 0,
+ LineSpacing = value.LineSpacing ?? 240,
+ LineSpacingRule = value.LineSpacingRule ?? LineSpacingRule.Auto,
+ KeepNext = value.KeepNext ?? false,
+ KeepLines = value.KeepLines ?? false,
+ PageBreakBefore = value.PageBreakBefore ?? false,
+ };
+ }
+
+ private static RunFormattingInfo ParseRun(
+ XElement? rPr, bool effective, string? effectiveStyleId)
+ {
+ var styleId = effectiveStyleId
+ ?? (string?)rPr?.Element(W.rStyle)?.Attribute(W.val);
+ var underlineElement = rPr?.Element(W.u);
+ var underlineStyle = (string?)underlineElement?.Attribute(W.val);
+ bool? underline = underlineElement is null
+ ? null
+ : !string.Equals(underlineStyle, "none", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(underlineStyle, "0", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(underlineStyle, "false", StringComparison.OrdinalIgnoreCase);
+ if (underline == true && string.IsNullOrEmpty(underlineStyle)) underlineStyle = "single";
+
+ var sizeHalfPoints = ReadIntAttribute(rPr?.Element(W.sz)?.Attribute(W.val));
+ var fonts = rPr?.Element(W.rFonts);
+ var value = new RunFormattingInfo
+ {
+ StyleId = styleId,
+ Bold = ReadOnOffElement(rPr?.Element(W.b)),
+ Italic = ReadOnOffElement(rPr?.Element(W.i)),
+ Underline = underline,
+ UnderlineStyle = underlineStyle,
+ Strike = ReadOnOffElement(rPr?.Element(W.strike))
+ ?? ReadOnOffElement(rPr?.Element(W.dstrike)),
+ Code = styleId is null ? null : string.Equals(styleId, "Code", StringComparison.Ordinal),
+ Color = (string?)rPr?.Element(W.color)?.Attribute(W.val),
+ Highlight = (string?)rPr?.Element(W.highlight)?.Attribute(W.val),
+ VertAlign = (string?)rPr?.Element(W.vertAlign)?.Attribute(W.val),
+ FontSizePts = sizeHalfPoints is null ? null : sizeHalfPoints.Value / 2.0,
+ FontFamily = (string?)fonts?.Attribute(W.ascii)
+ ?? (string?)fonts?.Attribute(W.hAnsi)
+ ?? (string?)fonts?.Attribute(W.cs),
+ Caps = ReadOnOffElement(rPr?.Element(W.caps)),
+ SmallCaps = ReadOnOffElement(rPr?.Element(W.smallCaps)),
+ Hidden = ReadOnOffElement(rPr?.Element(W.vanish)),
+ };
+
+ if (!effective) return value;
+ return value with
+ {
+ Bold = value.Bold ?? false,
+ Italic = value.Italic ?? false,
+ Underline = value.Underline ?? false,
+ Strike = value.Strike ?? false,
+ Code = value.Code ?? false,
+ Caps = value.Caps ?? false,
+ SmallCaps = value.SmallCaps ?? false,
+ Hidden = value.Hidden ?? false,
+ };
+ }
+
+ private static TableStyleFormatting ParseTableStyle(XElement style)
+ {
+ var tblPr = style.Element(W.tblPr);
+ var width = tblPr?.Element(W.tblW);
+ var indent = tblPr?.Element(W.tblInd);
+ var widthTwips = string.Equals((string?)width?.Attribute(W.type), "dxa", StringComparison.Ordinal)
+ ? ReadIntAttribute(width?.Attribute(W._w))
+ : null;
+ var indentTwips = string.Equals((string?)indent?.Attribute(W.type), "dxa", StringComparison.Ordinal)
+ ? ReadIntAttribute(indent?.Attribute(W._w))
+ : null;
+ return new TableStyleFormatting
+ {
+ Alignment = (string?)tblPr?.Element(W.jc)?.Attribute(W.val),
+ WidthTwips = widthTwips,
+ IndentTwips = indentTwips,
+ Layout = (string?)tblPr?.Element(W.tblLayout)?.Attribute(W.type),
+ HasBorders = tblPr?.Element(W.tblBorders)?.Elements().Any(),
+ CellShadingFill = (string?)style.Element(W.tcPr)?.Element(W.shd)?.Attribute(W.fill),
+ };
+ }
+
+ private static ParagraphBorderEdge? ParseBorder(XElement? edge)
+ {
+ if (edge is null) return null;
+ return new ParagraphBorderEdge
+ {
+ Style = (string?)edge.Attribute(W.val),
+ Size = ReadIntAttribute(edge.Attribute(W.sz)),
+ Color = (string?)edge.Attribute(W.color),
+ Space = ReadIntAttribute(edge.Attribute(W.space)),
+ };
+ }
+
+ private static ParagraphAlignment? ParseAlignment(string? raw) => raw switch
+ {
+ "left" or "start" => ParagraphAlignment.Left,
+ "center" => ParagraphAlignment.Center,
+ "right" or "end" => ParagraphAlignment.Right,
+ "both" or "distribute" => ParagraphAlignment.Justify,
+ _ => null,
+ };
+
+ private static LineSpacingRule? ParseLineSpacingRule(string? raw) => raw switch
+ {
+ "auto" => LineSpacingRule.Auto,
+ "exact" => LineSpacingRule.Exact,
+ "atLeast" => LineSpacingRule.AtLeast,
+ _ => null,
+ };
+
+ private static bool? ResolveGalleryBool(
+ XElement style, XName styleName,
+ XElement? exception, XName exceptionName, XElement? latent, XName defaultName) =>
+ ReadOnOffElement(style.Element(styleName))
+ ?? ReadOnOffAttribute(exception?.Attribute(exceptionName))
+ ?? ReadOnOffAttribute(latent?.Attribute(defaultName));
+
+ private static int? ReadIntAttribute(XAttribute? attribute) =>
+ int.TryParse((string?)attribute, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : null;
+
+ private static bool? ReadOnOffElement(XElement? element)
+ {
+ if (element is null) return null;
+ var attribute = element.Attribute(W.val);
+ // ST_OnOff: a toggle element written without w:val is "on". Only an ABSENT attribute
+ // means that — an unreadable one means "unknown", never "on".
+ return attribute is null ? true : ReadOnOffAttribute(attribute);
+ }
+
+ ///
+ /// Parse an ST_OnOff attribute the way the renderer does. Single owner:
+ /// is the parser and
+ /// the whole render path already use — it lowercases first, so a writer emitting
+ /// bool.ToString() (w:val="False") is read here exactly as the rendered
+ /// document reads it. Its fallback cast throws for values outside ST_OnOff; a read
+ /// API over arbitrary uploaded documents reports "unknown" instead of failing the query.
+ ///
+ private static bool? ReadOnOffAttribute(XAttribute? attribute)
+ {
+ if (attribute is null) return null;
+ try
+ {
+ return attribute.ToBoolean();
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/Docxodus/Internal/HtmlConversionOps.cs b/Docxodus/Internal/HtmlConversionOps.cs
index 97a0adb1..10cd7454 100644
--- a/Docxodus/Internal/HtmlConversionOps.cs
+++ b/Docxodus/Internal/HtmlConversionOps.cs
@@ -407,6 +407,10 @@ private static string AnchorScope(string anchorId)
if ((string?)t.Attribute(PtOpenXml.Unid) is { } u) wantedUnids.Add(u);
}
+ // The session's own anchor index IS the identity the caller addressed these blocks by,
+ // and it is cached, so repeated stamped renders do not rebuild it.
+ var identity = BlockSourceIdentity.For(options.StampAnchors, session.AnchorIndex(), liveDoc);
+
// Per parent: order block-level children, merge each target's ±1 window into runs.
var bodyContent = new List();
foreach (var parentGroup in targets.Where(t => t.Parent is not null).GroupBy(t => t.Parent!))
@@ -431,7 +435,11 @@ private static string AnchorScope(string anchorId)
}
idx++;
for (int i = start; i <= end; i++)
- bodyContent.Add(CloneWithListAnnotations(siblings[i]));
+ {
+ var clone = CloneWithListAnnotations(siblings[i]);
+ identity?.Record(siblings[i], clone);
+ bodyContent.Add(clone);
+ }
}
}
@@ -450,7 +458,10 @@ private static string AnchorScope(string anchorId)
renderDoc.MainDocumentPart!.PutXDocument(
BuildBodyDocument(bodyContent.Cast