From d5578568ddea2aee50cb89c8e9da021974a6a5f7 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 02:42:56 -0500 Subject: [PATCH 1/3] Add portable page citation maps --- CHANGELOG.md | 12 + Docxodus.Tests/DocumentMetadataTests.cs | 1 + Docxodus.Tests/McpServerDispatcherTests.cs | 87 +++- Docxodus.Tests/PageMapSourceIdentityTests.cs | 160 ++++++++ Docxodus.Tests/PageMapTests.cs | 281 +++++++++++++ Docxodus/DocxSession.cs | 353 +++++++++++++++- Docxodus/Internal/DocxSessionJson.cs | 213 +++++++++- Docxodus/Internal/DocxSessionOps.cs | 59 ++- Docxodus/Internal/HtmlConversionOps.cs | 16 +- Docxodus/PageMap.cs | 141 +++++++ Docxodus/WmlToHtmlConverter.cs | 80 +++- Docxodus/WmlToMarkdownConverter.cs | 9 + docs/architecture/docx_agent_server.md | 22 +- docs/architecture/page_map.md | 107 +++++ npm/README.md | 17 + npm/src/docxodus.worker.ts | 1 + npm/src/index.ts | 16 +- npm/src/pagination.ts | 400 ++++++++++++++++++- npm/src/react.ts | 45 ++- npm/src/session.ts | 78 +++- npm/src/types.ts | 80 ++++ npm/tests/page-map.spec.ts | 292 ++++++++++++++ python/README.md | 9 +- python/src/docx_scalpel/__init__.py | 16 + python/src/docx_scalpel/session.py | 139 +++++-- python/src/docx_scalpel/types.py | 182 ++++++++- python/tests/test_page_map.py | 67 ++++ tools/mcp-server/Dispatcher.cs | 52 ++- tools/mcp-server/README.md | 8 +- tools/mcp-server/ToolCatalog.cs | 24 +- tools/mcp-server/UiResources.cs | 64 ++- tools/python-host/Dispatcher.cs | 41 +- wasm/DocxodusWasm/DocumentConverter.cs | 1 + wasm/DocxodusWasm/DocxSessionBridge.cs | 90 ++++- wasm/DocxodusWasm/JsonContext.cs | 1 + 35 files changed, 3026 insertions(+), 138 deletions(-) create mode 100644 Docxodus.Tests/PageMapSourceIdentityTests.cs create mode 100644 Docxodus.Tests/PageMapTests.cs create mode 100644 Docxodus/PageMap.cs create mode 100644 docs/architecture/page_map.md create mode 100644 npm/tests/page-map.spec.ts create mode 100644 python/tests/test_page_map.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ef7a7716..3e6db8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,18 @@ All notable changes to this project will be documented in this file. preserved; emitting new tracked table revisions remains #455. Coverage: `DocxSessionTableAddressingTests` DT250–DT257, the existing table/MCP suites, and `python/tests/test_table_addressing.py`. +- **Portable, renderer-authored `PageMap` and exact page citations** (issue #454). + Browser pagination can now materialize a versioned map of physical pages and every + canonical `kind:scope:unid` source fragment, with page-relative point geometry, + story/table ownership, page style identity, document version, and renderer + fingerprint. `DocxSession` validates and registers external maps; search, structural + find, and scoped projection APIs optionally attach citations across .NET, WASM/npm, + stdio/Python, and MCP. Mutations stale maps automatically, fingerprint mismatches are + rejected, and continuous/no-map layouts return typed unavailable results instead of + guessed pages. `paginateHtml`, React `PaginatedDocument`, and + `navigateToPageCitation` expose materialization and preview navigation. The MCP inline + preview remains explicitly continuous pending #434. See + [`docs/architecture/page_map.md`](docs/architecture/page_map.md). - **Optimistic mutation preconditions and a monotonic document version** (issue #447). Every `DocxSession` starts at version `0` and advances exactly once for each committed mutation, undo, or redo; failures and successful no-ops leave it diff --git a/Docxodus.Tests/DocumentMetadataTests.cs b/Docxodus.Tests/DocumentMetadataTests.cs index 9d2028f1..efe80106 100644 --- a/Docxodus.Tests/DocumentMetadataTests.cs +++ b/Docxodus.Tests/DocumentMetadataTests.cs @@ -42,6 +42,7 @@ public void DM001_GetDocumentMetadata_ReturnsValidMetadata() Assert.True(metadata.TotalParagraphs >= 0, "Total paragraphs should be non-negative"); Assert.True(metadata.TotalTables >= 0, "Total tables should be non-negative"); Assert.True(metadata.EstimatedPageCount >= 1, "Estimated page count should be at least 1"); + Assert.Equal("heuristic", metadata.EstimatedPageCountSource); } [Fact] diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs index f92840d3..5b7fe256 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -305,6 +305,81 @@ public void MCP031_Search_KindMode_FindsParagraphs() Assert.True(found.GetProperty("matches").GetArrayLength() > 0); } + [Fact] + public void MCP032_Pagination_RegisterSearchPreviewAndStaleStatus() + { + var sessionId = OpenSession(); + var anchor = FirstBodyAnchorId(sessionId, _store); + Assert.True(ReplaceText(_store, sessionId, anchor, "citation target") + .GetProperty("success").GetBoolean()); + + var version = Parse(Dispatcher.Call(_store, "docxodus_get_content", J( + JsonSerializer.Serialize(new { sessionId, format = "version" })))) + .GetProperty("version").GetInt64(); + const string fingerprint = "mcp-page-map-v1"; + var pageMap = new + { + schemaVersion = 1, + mode = "paginated", + availability = "available", + documentVersion = version, + rendererFingerprint = fingerprint, + pages = new[] + { + new + { + pageNumber = 1, + pageInSection = 1, + width = 612, + height = 792, + sectionIndex = 0, + pageName = "docxodus-section-0", + }, + }, + fragments = new[] + { + new + { + fragmentId = $"p1-f0-{anchor}", + anchorId = anchor, + fragmentIndex = 0, + pageNumber = 1, + geometry = new { x = 72, y = 90, width = 468, height = 18 }, + story = "body", + inTableCell = false, + }, + }, + }; + var registered = Parse(Dispatcher.Call(_store, "docxodus_pagination", J( + JsonSerializer.Serialize(new { sessionId, action = "register", pageMap })))); + Assert.True(registered.GetProperty("success").GetBoolean()); + + var citation = new { documentVersion = version, rendererFingerprint = fingerprint }; + var found = Parse(Dispatcher.Call(_store, "docxodus_search", J( + JsonSerializer.Serialize(new + { + sessionId, + mode = "text", + query = "citation target", + citation, + })))); + Assert.Equal("available", found.GetProperty("matches")[0] + .GetProperty("citation").GetProperty("availability").GetString()); + + var preview = Parse(Dispatcher.Call(_store, "docxodus_preview", J( + JsonSerializer.Serialize(new { sessionId, anchorId = anchor, citation })))); + Assert.Equal("unavailable_continuous_preview", + preview.GetProperty("pageNavigation").GetString()); + Assert.Equal(1, preview.GetProperty("citation").GetProperty("fragments")[0] + .GetProperty("pageNumber").GetInt32()); + + Assert.True(ReplaceText(_store, sessionId, anchor, "changed") + .GetProperty("success").GetBoolean()); + var stale = Parse(Dispatcher.Call(_store, "docxodus_pagination", J( + JsonSerializer.Serialize(new { sessionId, action = "status", citation })))); + Assert.Equal("stale_document_version", stale.GetProperty("unavailableReason").GetString()); + } + // ─── Format / List ────────────────────────────────────────────────── [Fact] @@ -954,9 +1029,9 @@ public void MCP092_Mutations_RejectsUndoRedoAsSteps() // ─── Tool catalog ─────────────────────────────────────────────────── [Fact] - public void MCP100_ToolCatalog_HasFifteenDistinctNamedToolsWithValidSchemas() + public void MCP100_ToolCatalog_HasSixteenDistinctNamedToolsWithValidSchemas() { - Assert.Equal(15, ToolCatalog.Tools.Count); + Assert.Equal(16, ToolCatalog.Tools.Count); var names = new System.Collections.Generic.HashSet(); foreach (var tool in ToolCatalog.Tools) { @@ -1031,6 +1106,13 @@ public void MCP141_WrapToolResult_RoutesHtmlToMetaNotModelContent() Assert.Equal("big".Length, structured.GetProperty("htmlLength").GetInt32()); + var cited = Parse(UiResources.WrapToolResult("docxodus_preview", + """{"sessionId":"s1","html":"

x

","citation":{"availability":"available","fragments":[{"pageNumber":3}]},"pageNavigation":"unavailable_continuous_preview"}""", + isError: false)).GetProperty("structuredContent"); + Assert.Equal(3, cited.GetProperty("citation").GetProperty("fragments")[0] + .GetProperty("pageNumber").GetInt32()); + Assert.Equal("unavailable_continuous_preview", cited.GetProperty("pageNavigation").GetString()); + // docxodus_open mirrors its result as structuredContent for the widget… var open = Parse(UiResources.WrapToolResult("docxodus_open", """{"sessionId":"s1","path":"a.docx"}""", isError: false)); @@ -1058,6 +1140,7 @@ public void MCP142_UiResources_ServeViewerTemplate() var htmlText = contents.GetProperty("text").GetString()!; Assert.StartsWith("", htmlText.TrimStart()); Assert.Contains("docxodus_preview", htmlText); // the widget's refresh path + Assert.Contains("unavailable_continuous_preview", htmlText); Assert.True(contents.GetProperty("_meta").TryGetProperty("ui", out _)); Assert.Throws(() => diff --git a/Docxodus.Tests/PageMapSourceIdentityTests.cs b/Docxodus.Tests/PageMapSourceIdentityTests.cs new file mode 100644 index 00000000..a383b0a9 --- /dev/null +++ b/Docxodus.Tests/PageMapSourceIdentityTests.cs @@ -0,0 +1,160 @@ +#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.IO; +using System.Linq; +using System.Xml.Linq; +using DocumentFormat.OpenXml.Packaging; +using Docxodus; +using Docxodus.Internal; +using Xunit; + +namespace Docxodus.Tests; + +public class PageMapSourceIdentityTests +{ + [Fact] + public void PM100_FinalConverterTreesCarryCanonicalIdentityAcrossEveryRenderedStoryAndTableLevel() + { + using var session = new DocxSession( + DocxSessionTests.BuildDS003_TableWithCells(), + new DocxSessionSettings + { + TrackedChanges = TrackedChangeMode.RenderInline, + RevisionAuthor = "PageMap test", + }); + var body = session.Project().AnchorIndex.Values + .First(target => target.Anchor.Scope == "body" && target.TextPreview == "After table."); + + Assert.True(session.SetHeaderText(body.Anchor.Id, HeaderFooterKind.Default, "After table.").Success); + Assert.True(session.SetFooterText(body.Anchor.Id, HeaderFooterKind.Default, "Footer source").Success); + Assert.True(session.InsertFootnote(body.Anchor.Id, 5, "Footnote source").Success); + Assert.True(session.InsertEndnote(body.Anchor.Id, 6, "Endnote source").Success); + Assert.True(session.AddComment( + body.Anchor.Id, + new CharSpan(0, 5), + "Reviewer", + "Comment source").Success); + + Assert.True(session.ReplaceText(body.Anchor.Id, "Tracked replacement").Success); + + var projection = session.Project(); + var html = XElement.Parse(HtmlConversionOps.ConvertToHtml(session, new HtmlConversionOptions + { + StampAnchors = true, + FabricateCssClasses = false, + RenderHeadersAndFooters = true, + RenderFootnotesAndEndnotes = true, + CommentRenderMode = (int)CommentRenderMode.EndnoteStyle, + RenderTrackedChanges = true, + })); + var sourceIds = html.DescendantsAndSelf() + .Attributes("data-source-anchor-id") + .Select(attribute => attribute.Value) + .ToHashSet(StringComparer.Ordinal); + + void AssertRendered(string kind, Func scope) + { + var candidates = projection.AnchorIndex.Values + .Where(target => target.Anchor.Kind == kind && scope(target.Anchor.Scope)) + .Select(target => target.Anchor.Id) + .Distinct(StringComparer.Ordinal) + .ToArray(); + Assert.NotEmpty(candidates); + Assert.Contains(candidates, sourceIds.Contains); + } + + AssertRendered("p", scope => scope == "body"); + AssertRendered("tbl", scope => scope == "body"); + AssertRendered("tr", scope => scope == "body"); + AssertRendered("tc", scope => scope == "body"); + AssertRendered("p", scope => scope.StartsWith("hdr", StringComparison.Ordinal)); + AssertRendered("p", scope => scope.StartsWith("ftr", StringComparison.Ordinal)); + AssertRendered("fn", scope => scope == "fn"); + AssertRendered("p", scope => scope == "fn"); + AssertRendered("en", scope => scope == "en"); + AssertRendered("p", scope => scope == "en"); + AssertRendered("cmt", scope => scope == "cmt"); + AssertRendered("p", scope => scope == "cmt"); + + var tracked = projection.AnchorIndex.Values.Single(target => + target.Anchor.Scope == "body" && target.TextPreview == "Tracked replacement"); + Assert.Contains(tracked.Anchor.Id, sourceIds); + Assert.Contains("rev-", html.ToString(SaveOptions.DisableFormatting)); + + using var inlineSession = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var inlineBody = inlineSession.Project().AnchorIndex.Values + .First(target => target.Anchor.Scope == "body" && target.TextPreview == "First paragraph."); + Assert.True(inlineSession.AddComment( + inlineBody.Anchor.Id, new CharSpan(0, 5), "Reviewer", "Inline comment").Success); + var inlineProjection = inlineSession.Project(); + var inlineHtml = XElement.Parse(HtmlConversionOps.ConvertToHtml(inlineSession, new HtmlConversionOptions + { + StampAnchors = true, + FabricateCssClasses = false, + CommentRenderMode = (int)CommentRenderMode.Inline, + })); + var commentDefinition = inlineProjection.AnchorIndex.Values.Single(target => target.Anchor.Kind == "cmt"); + Assert.Contains(inlineHtml.DescendantsAndSelf(), element => + (string?)element.Attribute("data-source-anchor-id") == commentDefinition.Anchor.Id); + } + + [Fact] + public void PM101_BareUnidCollisionAcrossStoriesKeepsDistinctCanonicalSourceIdentity() + { + byte[] collisionBytes; + using (var seedSession = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs())) + { + var body = seedSession.Project().AnchorIndex.Values.First(target => target.TextPreview == "First paragraph."); + Assert.True(seedSession.SetHeaderText( + body.Anchor.Id, + HeaderFooterKind.Default, + "First paragraph.").Success); + + using var packageStream = new MemoryStream(); + packageStream.Write(seedSession.Save(persistAnchorIds: true)); + packageStream.Position = 0; + using (var document = WordprocessingDocument.Open(packageStream, true)) + { + var mainPart = document.MainDocumentPart!; + var bodyParagraph = mainPart.GetXDocument().Descendants(W.p) + .First(paragraph => paragraph.Value == "First paragraph."); + var headerPart = mainPart.HeaderParts.Single(); + var headerParagraph = headerPart.GetXDocument().Descendants(W.p) + .First(paragraph => paragraph.Value == "First paragraph."); + var sharedUnid = (string)bodyParagraph.Attribute(PtOpenXml.Unid)!; + headerParagraph.SetAttributeValue(PtOpenXml.Unid, sharedUnid); + headerPart.PutXDocument(); + } + + collisionBytes = packageStream.ToArray(); + } + + using var session = new DocxSession(collisionBytes); + + var projection = session.Project(); + var bodySame = projection.AnchorIndex.Values.Single(target => + target.Anchor.Scope == "body" && target.TextPreview == "First paragraph."); + var headerSame = projection.AnchorIndex.Values.Single(target => + target.Anchor.Scope.StartsWith("hdr", StringComparison.Ordinal) + && target.TextPreview == "First paragraph."); + Assert.Equal(bodySame.Unid, headerSame.Unid); + Assert.NotEqual(bodySame.Anchor.Id, headerSame.Anchor.Id); + + var html = XElement.Parse(HtmlConversionOps.ConvertToHtml(session, new HtmlConversionOptions + { + StampAnchors = true, + FabricateCssClasses = false, + RenderHeadersAndFooters = true, + })); + var identities = html.DescendantsAndSelf() + .Attributes("data-source-anchor-id") + .Select(attribute => attribute.Value) + .ToArray(); + Assert.Contains(bodySame.Anchor.Id, identities); + Assert.Contains(headerSame.Anchor.Id, identities); + } +} diff --git a/Docxodus.Tests/PageMapTests.cs b/Docxodus.Tests/PageMapTests.cs new file mode 100644 index 00000000..5fae0632 --- /dev/null +++ b/Docxodus.Tests/PageMapTests.cs @@ -0,0 +1,281 @@ +#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.Linq; +using Docxodus; +using Docxodus.Internal; +using Xunit; + +namespace Docxodus.Tests; + +public class PageMapTests +{ + private const string Fingerprint = "chromium-140|docxodus-pagination-v1"; + + private static PageMapPage Page( + int pageNumber = 1, + int pageInSection = 1, + double width = 612, + double height = 792, + string pageName = "docxodus-section-0", + int? sectionIndex = 0) => new() + { + PageNumber = pageNumber, + PageInSection = pageInSection, + Width = width, + Height = height, + SectionIndex = sectionIndex, + PageName = pageName, + }; + + private static PageMapFragment Fragment( + string anchorId, + int fragmentIndex = 0, + int pageNumber = 1, + PageMapStory story = PageMapStory.Body, + bool inTableCell = false, + PageMapRect? geometry = null) => new() + { + FragmentId = $"p{pageNumber}-f{fragmentIndex}-{anchorId}", + AnchorId = anchorId, + FragmentIndex = fragmentIndex, + PageNumber = pageNumber, + Geometry = geometry ?? new PageMapRect(72, 90, 300, 18), + Story = story, + InTableCell = inTableCell, + }; + + private static PageMap AvailableMap( + DocxSession session, + IReadOnlyList fragments, + IReadOnlyList? pages = null, + string fingerprint = Fingerprint) => new() + { + Mode = PageMapMode.Paginated, + Availability = PageMapAvailability.Available, + DocumentVersion = session.Version, + RendererFingerprint = fingerprint, + Pages = pages ?? new[] { Page() }, + Fragments = fragments, + }; + + [Fact] + public void PM001_NoMapAndContinuousModeAreExplicitlyUnavailable() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var request = new PageCitationRequest(session.Version, Fingerprint); + var anchor = session.Project().AnchorIndex.Values.First().Anchor.Id; + + Assert.Equal(PageCitationUnavailableReason.NoPageMap, + session.GetPageMapStatus(request).UnavailableReason); + Assert.Equal(PageCitationUnavailableReason.NoPageMap, + session.GetPageCitation(anchor, request).UnavailableReason); + + var continuous = new PageMap + { + Mode = PageMapMode.Continuous, + Availability = PageMapAvailability.Unavailable, + DocumentVersion = session.Version, + RendererFingerprint = Fingerprint, + }; + Assert.True(session.RegisterPageMap(continuous).Success); + Assert.Equal(PageCitationUnavailableReason.ContinuousMode, + session.GetPageMapStatus(request).UnavailableReason); + Assert.Equal(PageCitationUnavailableReason.ContinuousMode, + session.GetPageCitation(anchor, request).UnavailableReason); + } + + [Fact] + public void PM002_ValidMapFeedsCitationsIntoSearchAndScopedProjection() + { + var annotated = AnnotationManager.AddAnnotation( + new WmlDocument("PM002.docx", DocxSessionTests.BuildDS001_SimpleTwoParagraphs()), + new DocumentAnnotation("page-map-annotation", "PAGE_MAP", "Page map", "#FFFF00"), + AnnotationRange.FromSearch("First paragraph.")); + using var session = new DocxSession(annotated.DocumentByteArray); + var anchors = session.Project().AnchorIndex.Values + .Where(target => target.Anchor.Kind == "p") + .Select(target => target.Anchor.Id) + .ToArray(); + var map = AvailableMap(session, new[] + { + Fragment(anchors[0], fragmentIndex: 0, pageNumber: 1), + Fragment(anchors[0], fragmentIndex: 1, pageNumber: 2), + Fragment(anchors[1], fragmentIndex: 0, pageNumber: 2), + }, new[] { Page(), Page(2, 2) }); + + Assert.True(session.RegisterPageMap(map, Fingerprint).Success); + var request = new PageCitationRequest(session.Version, Fingerprint); + var citation = session.GetPageCitation(anchors[0], request); + Assert.Equal(PageMapAvailability.Available, citation.Availability); + Assert.Equal(new[] { 1, 2 }, citation.Fragments.Select(fragment => fragment.PageNumber)); + + var match = Assert.Single(session.Grep("First", citationRequest: request)); + Assert.Equal(anchors[0], match.Citation?.AnchorId); + Assert.Equal(PageMapAvailability.Available, match.Citation?.Availability); + + var projection = session.ProjectAnchor( + anchors[0], ProjectionDepth.SelfOnly, citationRequest: request); + Assert.NotNull(projection.PageCitations); + Assert.Equal(PageMapAvailability.Available, + Assert.Single(projection.PageCitations!).Value.Availability); + + var labeled = Assert.Single(session.FindByLabel("PAGE_MAP", request)); + Assert.All(labeled.Value, target => + Assert.Equal(PageMapAvailability.Available, target.Citation?.Availability)); + } + + [Fact] + public void PM003_MutationAndRendererMismatchCannotConsumeARegisteredMap() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = session.Project().AnchorIndex.Values.First(target => target.Anchor.Kind == "p").Anchor.Id; + Assert.True(session.RegisterPageMap(AvailableMap(session, new[] { Fragment(anchor) })).Success); + + var wrongRenderer = new PageCitationRequest(session.Version, "firefox-other-layout"); + Assert.Equal(PageCitationUnavailableReason.RendererFingerprintMismatch, + session.GetPageCitation(anchor, wrongRenderer).UnavailableReason); + + var versionBeforeEdit = session.Version; + Assert.True(session.ReplaceText(anchor, "Changed paragraph.").Success); + Assert.True(session.Version > versionBeforeEdit); + var stale = session.GetPageMapStatus(new PageCitationRequest(versionBeforeEdit, Fingerprint)); + Assert.Equal(PageCitationUnavailableReason.StaleDocumentVersion, stale.UnavailableReason); + Assert.Equal(PageMapAvailability.Unavailable, stale.Availability); + } + + [Fact] + public void PM004_RegistrationRejectsWrongVersionAndExpectedRenderer() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = session.Project().AnchorIndex.Values.First(target => target.Anchor.Kind == "p").Anchor.Id; + var map = AvailableMap(session, new[] { Fragment(anchor) }); + + Assert.Equal(PageMapRegistrationError.RendererFingerprintMismatch, + session.RegisterPageMap(map, "another-renderer").Error); + Assert.Equal(PageMapRegistrationError.StaleDocumentVersion, + session.RegisterPageMap(map with { DocumentVersion = session.Version + 1 }).Error); + } + + [Fact] + public void PM005_ValidatorEnforcesPageFragmentAndOwnershipInvariants() + { + using var session = new DocxSession(DocxSessionTests.BuildDS003_TableWithCells()); + var projection = session.Project(); + var cellParagraph = projection.AnchorIndex.Values.First(target => + target.Anchor.Kind == "p" && target.TextPreview == "R0C0"); + + PageMapRegistrationResult Register(PageMapFragment fragment, + IReadOnlyList? pages = null) => + session.RegisterPageMap(AvailableMap(session, new[] { fragment }, pages)); + + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, inTableCell: false)).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, story: PageMapStory.Header, inTableCell: true)).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, inTableCell: true, + geometry: new PageMapRect(600, 780, 20, 20))).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, fragmentIndex: 1, inTableCell: true)).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, inTableCell: true), + new[] { Page(2, 1) }).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, inTableCell: true), + new[] { Page(pageName: "") }).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, inTableCell: true), + new[] { Page(sectionIndex: -1) }).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, inTableCell: true), + new[] { Page(2), Page(1, 2) }).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + Register(Fragment(cellParagraph.Anchor.Id, inTableCell: true), + new[] { Page(), Page(2, 3) }).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + session.RegisterPageMap(AvailableMap(session, new[] + { + Fragment(cellParagraph.Anchor.Id, fragmentIndex: 1, pageNumber: 1, inTableCell: true), + Fragment(cellParagraph.Anchor.Id, fragmentIndex: 0, pageNumber: 2, inTableCell: true), + }, new[] { Page(), Page(2, 2) })).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + session.RegisterPageMap(AvailableMap(session, new[] + { + Fragment(cellParagraph.Anchor.Id, fragmentIndex: 0, pageNumber: 2, inTableCell: true), + Fragment(cellParagraph.Anchor.Id, fragmentIndex: 1, pageNumber: 1, inTableCell: true), + }, new[] { Page(), Page(2, 2) })).Error); + + var secondCellParagraph = projection.AnchorIndex.Values.First(target => + target.Anchor.Kind == "p" && target.TextPreview == "R0C1"); + Assert.Equal(PageMapRegistrationError.InvalidMap, + session.RegisterPageMap(AvailableMap(session, new[] + { + Fragment(cellParagraph.Anchor.Id, pageNumber: 2, inTableCell: true), + Fragment(secondCellParagraph.Anchor.Id, pageNumber: 1, inTableCell: true), + }, new[] { Page(), Page(2, 2) })).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + session.RegisterPageMap(AvailableMap(session, new PageMapFragment[] { null! })).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + session.RegisterPageMap(AvailableMap(session, new[] + { + Fragment(cellParagraph.Anchor.Id, inTableCell: true) with { Geometry = null! }, + })).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + session.RegisterPageMap(AvailableMap( + session, new[] { Fragment(cellParagraph.Anchor.Id, inTableCell: true) }, + new PageMapPage[] { null! })).Error); + + Assert.True(Register(Fragment(cellParagraph.Anchor.Id, inTableCell: true)).Success); + } + + [Fact] + public void PM006_UnknownContractDiscriminatorsAreNeverSilentlyCoerced() + { + const string typoStory = """ + { + "schemaVersion":1, + "mode":"paginated", + "availability":"available", + "documentVersion":0, + "rendererFingerprint":"renderer", + "pages":[], + "fragments":[{ + "fragmentId":"f", "anchorId":"p:body:u", "fragmentIndex":0, + "pageNumber":1, "geometry":{"x":0,"y":0,"width":1,"height":1}, + "story":"boddy", "inTableCell":false + }] + } + """; + Assert.Throws(() => DocxSessionJson.ParsePageMap(typoStory)); + + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = session.Project().AnchorIndex.Values.First(target => target.Anchor.Kind == "p").Anchor.Id; + var invalid = AvailableMap(session, new[] + { + Fragment(anchor) with { Story = (PageMapStory)999 }, + }); + Assert.Equal(PageMapRegistrationError.InvalidMap, session.RegisterPageMap(invalid).Error); + Assert.Equal(PageMapRegistrationError.InvalidMap, + session.RegisterPageMap(invalid with { Mode = (PageMapMode)999 }).Error); + } + + [Fact] + public void PM007_PlaceholderSearchCanAttachACitation() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = session.Project().AnchorIndex.Values + .First(target => target.Anchor.Kind == "p").Anchor.Id; + Assert.True(session.ReplaceText(anchor, "Complete this: [___]").Success); + Assert.True(session.RegisterPageMap(AvailableMap(session, new[] { Fragment(anchor) })).Success); + + var match = Assert.Single(session.FindPlaceholders( + citationRequest: new PageCitationRequest(session.Version, Fingerprint))); + Assert.Equal(anchor, match.Match.Citation?.AnchorId); + Assert.Equal(PageMapAvailability.Available, match.Match.Citation?.Availability); + } +} diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 8e8b290c..ea2dd04e 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -415,6 +415,9 @@ public sealed record TextMatch /// Regex capture groups (index 0 is always the whole match; named groups appear at their numeric index). public IReadOnlyList Groups { get; init; } = Array.Empty(); + + /// Null unless the search requested page citations. + public PageCitation? Citation { get; init; } } /// @@ -463,6 +466,9 @@ public sealed record CrossBlockMatch /// Regex capture groups (index 0 is always the whole match; named groups appear at their numeric index). public IReadOnlyList Groups { get; init; } = Array.Empty(); + + /// One citation per entry when requested. + public IReadOnlyList? Citations { get; init; } } /// Options that tune the FindBy* helpers on . @@ -494,6 +500,9 @@ public sealed record FindOptions /// as a further narrowing — set both to restrict to one specific part inside /// a category. Most callers should use instead. public string? ScopeFilter { get; init; } + + /// Attach citations only if this exact registered layout is still valid. + public PageCitationRequest? CitationRequest { get; init; } } /// Convenience predicates over the flag set. @@ -1369,6 +1378,7 @@ public sealed class DocxSession : IDisposable private MarkdownProjection? _initialProjection; private bool _disposed; private long _version; + private PageMap? _registeredPageMap; private readonly object _mutationGate = new(); private int _revisionCounter = 1000; private long _lastFormatRevisionTicks; @@ -1410,6 +1420,273 @@ public DocxSession(byte[] docxBytes, DocxSessionSettings? settings = null) /// public long Version => _version; + /// + /// Validate and register an externally materialized layout map. Registration is read-only: + /// it neither changes the document version nor participates in undo. A later committed + /// mutation/undo/redo makes the map stale automatically because its document version no + /// longer matches . + /// + public PageMapRegistrationResult RegisterPageMap( + PageMap pageMap, string? expectedRendererFingerprint = null) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(pageMap); + + PageMapRegistrationResult Fail(PageMapRegistrationError error, string message) => + new() { Success = false, Error = error, Message = message }; + + if (pageMap.SchemaVersion != PageMap.CurrentSchemaVersion) + return Fail(PageMapRegistrationError.UnsupportedSchemaVersion, + $"unsupported PageMap schemaVersion {pageMap.SchemaVersion}; expected {PageMap.CurrentSchemaVersion}"); + if (pageMap.DocumentVersion != _version) + return Fail(PageMapRegistrationError.StaleDocumentVersion, + $"PageMap documentVersion {pageMap.DocumentVersion} does not match session version {_version}"); + if (!Enum.IsDefined(pageMap.Mode) || !Enum.IsDefined(pageMap.Availability)) + return Fail(PageMapRegistrationError.InvalidMap, "PageMap mode or availability discriminator is invalid"); + if (string.IsNullOrWhiteSpace(pageMap.RendererFingerprint)) + return Fail(PageMapRegistrationError.InvalidMap, "rendererFingerprint must be non-empty"); + if (pageMap.Pages is null || pageMap.Fragments is null) + return Fail(PageMapRegistrationError.InvalidMap, "PageMap pages and fragments arrays are required"); + if (expectedRendererFingerprint is not null + && !string.Equals(pageMap.RendererFingerprint, expectedRendererFingerprint, StringComparison.Ordinal)) + return Fail(PageMapRegistrationError.RendererFingerprintMismatch, + "PageMap rendererFingerprint does not match the expected renderer"); + + if (pageMap.Mode == PageMapMode.Continuous) + { + if (pageMap.Availability != PageMapAvailability.Unavailable + || pageMap.Pages.Count != 0 || pageMap.Fragments.Count != 0) + return Fail(PageMapRegistrationError.InvalidMap, + "continuous PageMaps must be unavailable and contain no pages or fragments"); + } + else if (pageMap.Availability != PageMapAvailability.Available) + { + return Fail(PageMapRegistrationError.InvalidMap, + "paginated PageMaps must be explicitly available"); + } + else if (pageMap.Pages.Count == 0) + { + return Fail(PageMapRegistrationError.InvalidMap, + "an available paginated PageMap must contain at least one page"); + } + + var pagesByNumber = new Dictionary(); + var seenSectionIndices = new HashSet(); + PageMapPage? previousPage = null; + for (var pageIndex = 0; pageIndex < pageMap.Pages.Count; pageIndex++) + { + var page = pageMap.Pages[pageIndex]; + if (page is null) + return Fail(PageMapRegistrationError.InvalidMap, + "PageMap pages cannot contain null entries"); + if (page.PageNumber < 1 || page.PageInSection < 1 + || !double.IsFinite(page.Width) || page.Width <= 0 + || !double.IsFinite(page.Height) || page.Height <= 0 + || string.IsNullOrWhiteSpace(page.PageName) + || page.SectionIndex is < 0) + return Fail(PageMapRegistrationError.InvalidMap, + "pages require non-negative sectionIndex, positive numbering, a pageName, and finite positive geometry"); + if (page.PageNumber != pageIndex + 1) + return Fail(PageMapRegistrationError.InvalidMap, + "pages must appear in contiguous document order starting at 1"); + + if (pageIndex == 0) + { + if (page.PageInSection != 1) + return Fail(PageMapRegistrationError.InvalidMap, + "the first page must start at pageInSection 1"); + if (page.SectionIndex is int firstSection) seenSectionIndices.Add(firstSection); + } + else if (page.PageInSection == 1) + { + if (page.SectionIndex is int newSection + && !seenSectionIndices.Add(newSection)) + return Fail(PageMapRegistrationError.InvalidMap, + $"sectionIndex {newSection} appears in multiple discontiguous page runs"); + if (page.SectionIndex == previousPage!.SectionIndex + && page.SectionIndex is not null) + return Fail(PageMapRegistrationError.InvalidMap, + $"pageInSection resets within sectionIndex {page.SectionIndex}"); + } + else if (page.PageInSection != previousPage!.PageInSection + 1 + || page.SectionIndex != previousPage.SectionIndex) + { + return Fail(PageMapRegistrationError.InvalidMap, + "pageInSection must be contiguous and reset to 1 when the section changes"); + } + + pagesByNumber[page.PageNumber] = page; + previousPage = page; + } + + var fragmentIds = new HashSet(StringComparer.Ordinal); + var fragmentSequence = new Dictionary(StringComparer.Ordinal); + var lastFragmentPage = 0; + foreach (var fragment in pageMap.Fragments) + { + if (fragment is null || fragment.Geometry is null) + return Fail(PageMapRegistrationError.InvalidMap, + "PageMap fragments and fragment geometry cannot be null"); + if (string.IsNullOrWhiteSpace(fragment.FragmentId) + || !fragmentIds.Add(fragment.FragmentId) + || string.IsNullOrWhiteSpace(fragment.AnchorId) + || !Enum.IsDefined(fragment.Story) + || fragment.FragmentIndex < 0 + || !pagesByNumber.ContainsKey(fragment.PageNumber) + || !ValidRect(fragment.Geometry)) + return Fail(PageMapRegistrationError.InvalidMap, + "fragments require unique ids, canonical anchors, mapped pages, and finite non-negative geometry"); + if (fragment.PageNumber < lastFragmentPage) + return Fail(PageMapRegistrationError.InvalidMap, + "PageMap fragments must appear in nondecreasing page order"); + lastFragmentPage = fragment.PageNumber; + + var target = FindAnchor(fragment.AnchorId); + if (target is null || !string.Equals(target.Anchor.Id, fragment.AnchorId, StringComparison.Ordinal)) + return Fail(PageMapRegistrationError.InvalidMap, + $"PageMap fragment refers to unknown or non-canonical anchor: {fragment.AnchorId}"); + + if (!StoryMatchesScope(fragment.Story, target.Anchor.Scope)) + return Fail(PageMapRegistrationError.InvalidMap, + $"PageMap story does not match anchor scope: {fragment.AnchorId}"); + + var element = target.Resolve(_doc!); + var actuallyInTableCell = target.Anchor.Kind == "tc" + || (element?.AncestorsAndSelf(W.tc).Any() ?? false); + if (fragment.InTableCell != actuallyInTableCell) + return Fail(PageMapRegistrationError.InvalidMap, + $"PageMap inTableCell does not match anchor ownership: {fragment.AnchorId}"); + + var page = pagesByNumber[fragment.PageNumber]; + const double geometryTolerance = 0.25; + if (fragment.Geometry.X + fragment.Geometry.Width > page.Width + geometryTolerance + || fragment.Geometry.Y + fragment.Geometry.Height > page.Height + geometryTolerance) + return Fail(PageMapRegistrationError.InvalidMap, + $"PageMap fragment geometry exceeds page {fragment.PageNumber}"); + + if (!fragmentSequence.TryGetValue(fragment.AnchorId, out var sequence)) + sequence = (NextIndex: 0, LastPage: fragment.PageNumber); + if (fragment.FragmentIndex != sequence.NextIndex) + return Fail(PageMapRegistrationError.InvalidMap, + $"PageMap fragmentIndex values must appear contiguously from 0: {fragment.AnchorId}"); + if (fragment.PageNumber < sequence.LastPage) + return Fail(PageMapRegistrationError.InvalidMap, + $"PageMap fragment pages run backward for anchor: {fragment.AnchorId}"); + fragmentSequence[fragment.AnchorId] = (sequence.NextIndex + 1, fragment.PageNumber); + } + + _registeredPageMap = pageMap with + { + Pages = pageMap.Pages.ToArray(), + Fragments = pageMap.Fragments.ToArray(), + }; + return new PageMapRegistrationResult { Success = true }; + } + + /// Return explicit availability for the currently registered map. + public PageMapStatus GetPageMapStatus(PageCitationRequest? request = null) + { + ThrowIfDisposed(); + var map = _registeredPageMap; + if (map is null) + return new PageMapStatus + { + Availability = PageMapAvailability.Unavailable, + UnavailableReason = PageCitationUnavailableReason.NoPageMap, + DocumentVersion = _version, + }; + if (map.DocumentVersion != _version || (request is not null && request.DocumentVersion != _version)) + return new PageMapStatus + { + Availability = PageMapAvailability.Unavailable, + UnavailableReason = PageCitationUnavailableReason.StaleDocumentVersion, + DocumentVersion = _version, + RendererFingerprint = map.RendererFingerprint, + Mode = map.Mode, + }; + if (request is not null && !string.Equals( + request.RendererFingerprint, map.RendererFingerprint, StringComparison.Ordinal)) + return new PageMapStatus + { + Availability = PageMapAvailability.Unavailable, + UnavailableReason = PageCitationUnavailableReason.RendererFingerprintMismatch, + DocumentVersion = _version, + RendererFingerprint = map.RendererFingerprint, + Mode = map.Mode, + }; + if (map.Mode == PageMapMode.Continuous || map.Availability == PageMapAvailability.Unavailable) + return new PageMapStatus + { + Availability = PageMapAvailability.Unavailable, + UnavailableReason = PageCitationUnavailableReason.ContinuousMode, + DocumentVersion = _version, + RendererFingerprint = map.RendererFingerprint, + Mode = map.Mode, + }; + return new PageMapStatus + { + Availability = PageMapAvailability.Available, + DocumentVersion = _version, + RendererFingerprint = map.RendererFingerprint, + Mode = map.Mode, + }; + } + + /// Resolve every rendered fragment for one canonical anchor. + public PageCitation GetPageCitation(string anchorId, PageCitationRequest request) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(anchorId); + ArgumentNullException.ThrowIfNull(request); + var status = GetPageMapStatus(request); + if (status.Availability == PageMapAvailability.Unavailable) + return UnavailableCitation(anchorId, request, status.UnavailableReason!.Value); + + var fragments = _registeredPageMap!.Fragments + .Where(fragment => string.Equals(fragment.AnchorId, anchorId, StringComparison.Ordinal)) + .OrderBy(fragment => fragment.PageNumber) + .ThenBy(fragment => fragment.FragmentIndex) + .ToArray(); + if (fragments.Length == 0) + return UnavailableCitation(anchorId, request, PageCitationUnavailableReason.AnchorNotMapped); + return new PageCitation + { + AnchorId = anchorId, + Availability = PageMapAvailability.Available, + DocumentVersion = _version, + RendererFingerprint = request.RendererFingerprint, + Fragments = fragments, + }; + } + + private static bool ValidRect(PageMapRect rect) => + rect is not null + && double.IsFinite(rect.X) && rect.X >= 0 + && double.IsFinite(rect.Y) && rect.Y >= 0 + && double.IsFinite(rect.Width) && rect.Width > 0 + && double.IsFinite(rect.Height) && rect.Height > 0; + + private static bool StoryMatchesScope(PageMapStory story, string scope) => story switch + { + PageMapStory.Header => scope.StartsWith("hdr", StringComparison.Ordinal), + PageMapStory.Footer => scope.StartsWith("ftr", StringComparison.Ordinal), + PageMapStory.Footnote => scope == "fn", + PageMapStory.Endnote => scope == "en", + PageMapStory.Comment => scope == "cmt", + _ => scope == "body", + }; + + private PageCitation UnavailableCitation( + string anchorId, PageCitationRequest request, PageCitationUnavailableReason reason) => + new() + { + AnchorId = anchorId, + Availability = PageMapAvailability.Unavailable, + UnavailableReason = reason, + DocumentVersion = _version, + RendererFingerprint = request.RendererFingerprint, + }; + /// /// Set when a mutation threw AND the subsequent rollback to its pre-op snapshot ALSO threw — /// the one case in which a failed op can leave the document partially mutated. Null on a @@ -1492,7 +1769,8 @@ public MarkdownProjection Project() /// If isn't in the AnchorIndex. public MarkdownProjection ProjectAnchor( string anchorId, - ProjectionDepth depth = ProjectionDepth.SubtreeAndFollowingSiblings) + ProjectionDepth depth = ProjectionDepth.SubtreeAndFollowingSiblings, + PageCitationRequest? citationRequest = null) { ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(anchorId); @@ -1560,10 +1838,18 @@ public MarkdownProjection ProjectAnchor( filteredIndex[key] = value; } + var citations = citationRequest is null + ? null + : filteredIndex.Values + .Select(t => t.Anchor.Id) + .Distinct(StringComparer.Ordinal) + .ToDictionary(id => id, id => GetPageCitation(id, citationRequest), StringComparer.Ordinal); + return new MarkdownProjection { Markdown = sb.ToString().TrimEnd('\n'), AnchorIndex = filteredIndex, + PageCitations = citations, }; } @@ -2398,7 +2684,8 @@ public IReadOnlyList Grep( ProjectionScopes scope = ProjectionScopes.Body, int contextChars = 80, WhitespaceMode whitespace = WhitespaceMode.Preserve, - ContextBoundary boundary = ContextBoundary.Char) + ContextBoundary boundary = ContextBoundary.Char, + PageCitationRequest? citationRequest = null) { ThrowIfDisposed(); if (string.IsNullOrEmpty(pattern)) return Array.Empty(); @@ -2480,6 +2767,9 @@ public IReadOnlyList Grep( ContextBefore = ctxBefore, ContextAfter = ctxAfter, Groups = groups, + Citation = citationRequest is null + ? null + : GetPageCitation(target.Anchor.Id, citationRequest), }); } } @@ -2515,7 +2805,8 @@ public IReadOnlyList GrepCrossBlock( ProjectionScopes scope = ProjectionScopes.Body, int contextChars = 80, WhitespaceMode whitespace = WhitespaceMode.Preserve, - ContextBoundary boundary = ContextBoundary.Char) + ContextBoundary boundary = ContextBoundary.Char, + PageCitationRequest? citationRequest = null) { ThrowIfDisposed(); if (string.IsNullOrEmpty(pattern)) return Array.Empty(); @@ -2638,6 +2929,9 @@ public IReadOnlyList GrepCrossBlock( ContextBefore = ctxBefore, ContextAfter = ctxAfter, Groups = groups2, + Citations = citationRequest is null + ? null + : anchors.Select(a => GetPageCitation(a.Anchor.Id, citationRequest)).ToArray(), }); } } @@ -2681,7 +2975,10 @@ public IReadOnlyList FindByRegex( /// All anchors of a given kind (and optionally scope), in document order. Direct read /// over the projection's AnchorIndex; no text scan, so no . /// - public IReadOnlyList FindByKind(string kind, string? scope = null) + public IReadOnlyList FindByKind( + string kind, + string? scope = null, + PageCitationRequest? citationRequest = null) { ThrowIfDisposed(); var result = new List(); @@ -2689,7 +2986,7 @@ public IReadOnlyList FindByKind(string kind, string? scope = null) { if (target.Anchor.Kind != kind) continue; if (scope is not null && target.Anchor.Scope != scope) continue; - result.Add(target); + result.Add(AttachCitation(target, citationRequest)); } return result; } @@ -2708,7 +3005,8 @@ private IReadOnlyList FindMatchesFiltered( regexOptions, options.Scopes, contextChars: 0, - whitespace: options.IgnoreWhitespace ? WhitespaceMode.Normalize : WhitespaceMode.Preserve); + whitespace: options.IgnoreWhitespace ? WhitespaceMode.Normalize : WhitespaceMode.Preserve, + citationRequest: options.CitationRequest); var seen = new HashSet(StringComparer.Ordinal); var result = new List(); @@ -2718,7 +3016,7 @@ private IReadOnlyList FindMatchesFiltered( if (options.KindFilter is not null && anchor.Anchor.Kind != options.KindFilter) continue; if (options.ScopeFilter is not null && anchor.Anchor.Scope != options.ScopeFilter) continue; if (!seen.Add(anchor.Anchor.Id)) continue; - result.Add(anchor); + result.Add(AttachCitation(anchor, options.CitationRequest)); } return result; } @@ -2764,7 +3062,9 @@ public IReadOnlyList AnchorsByScope(ProjectionScopes scopes) /// yield each in document order. A finer-grained -aware return /// is left to a follow-up (see the issue's "Out of scope for v1"). /// - public IReadOnlyList FindByAnnotation(string annotationId) + public IReadOnlyList FindByAnnotation( + string annotationId, + PageCitationRequest? citationRequest = null) { ThrowIfDisposed(); if (string.IsNullOrEmpty(annotationId)) return Array.Empty(); @@ -2772,7 +3072,8 @@ public IReadOnlyList FindByAnnotation(string annotationId) .FirstOrDefault(a => string.Equals(a.Id, annotationId, StringComparison.Ordinal)); if (ann is null || string.IsNullOrEmpty(ann.BookmarkName)) return Array.Empty(); - return ResolveBookmarkAnchors(ann.BookmarkName); + return ResolveBookmarkAnchors(ann.BookmarkName) + .Select(target => AttachCitation(target, citationRequest)).ToArray(); } /// @@ -2782,7 +3083,9 @@ public IReadOnlyList FindByAnnotation(string annotationId) /// multiple regions (e.g. three separate "WARRANTY" annotations). Annotations whose /// bookmark is missing or resolves to no anchors are omitted from the result. /// - public IReadOnlyDictionary> FindByLabel(string labelId) + public IReadOnlyDictionary> FindByLabel( + string labelId, + PageCitationRequest? citationRequest = null) { ThrowIfDisposed(); var map = new Dictionary>(StringComparer.Ordinal); @@ -2791,8 +3094,9 @@ public IReadOnlyDictionary> FindByLabel(stri { if (!string.Equals(ann.LabelId, labelId, StringComparison.Ordinal)) continue; if (string.IsNullOrEmpty(ann.BookmarkName)) continue; - var anchors = ResolveBookmarkAnchors(ann.BookmarkName); - if (anchors.Count > 0) map[ann.Id] = anchors; + var anchors = ResolveBookmarkAnchors(ann.BookmarkName) + .Select(target => AttachCitation(target, citationRequest)).ToArray(); + if (anchors.Length > 0) map[ann.Id] = anchors; } return map; } @@ -2803,13 +3107,29 @@ public IReadOnlyDictionary> FindByLabel(stri /// bookmark name is unknown or its end marker is missing. Use this for raw bookmark /// names that didn't come from . /// - public IReadOnlyList FindByBookmark(string bookmarkName) + public IReadOnlyList FindByBookmark( + string bookmarkName, + PageCitationRequest? citationRequest = null) { ThrowIfDisposed(); if (string.IsNullOrEmpty(bookmarkName)) return Array.Empty(); - return ResolveBookmarkAnchors(bookmarkName); + return ResolveBookmarkAnchors(bookmarkName) + .Select(target => AttachCitation(target, citationRequest)).ToArray(); } + private AnchorTarget AttachCitation(AnchorTarget target, PageCitationRequest? request) => + request is null + ? target + : new AnchorTarget + { + Anchor = target.Anchor, + PartUri = target.PartUri, + Unid = target.Unid, + TextPreview = target.TextPreview, + AutoNumberPrefix = target.AutoNumberPrefix, + Citation = GetPageCitation(target.Anchor.Id, request), + }; + /// /// Enumerates every annotation persisted in the document — id, label id/text, color, /// author, and (when the bookmark resolves) the annotated text it covers. Lets an @@ -3156,7 +3476,8 @@ public IReadOnlyList FindPlaceholders( PlaceholderKinds kinds = PlaceholderKinds.All, ProjectionScopes scope = ProjectionScopes.Body, int contextChars = 80, - ContextBoundary boundary = ContextBoundary.Char) + ContextBoundary boundary = ContextBoundary.Char, + PageCitationRequest? citationRequest = null) { ThrowIfDisposed(); if (kinds == 0) return Array.Empty(); @@ -3166,7 +3487,7 @@ public IReadOnlyList FindPlaceholders( // crossing into a sibling bracket pair on the same line. var matches = Grep(@"\$?\[[^\[\]]+\]", System.Text.RegularExpressions.RegexOptions.None, scope, - contextChars, WhitespaceMode.Preserve, boundary); + contextChars, WhitespaceMode.Preserve, boundary, citationRequest); var results = new List(matches.Count); foreach (var m in matches) { diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs index a7ee32b0..f27af8f4 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -20,6 +20,101 @@ internal static class DocxSessionJson public static Position ParsePos(string s) => string.Equals(s, "before", System.StringComparison.OrdinalIgnoreCase) ? Position.Before : Position.After; + public static PageMap ParsePageMap(string json) + { + using var doc = JsonDocument.Parse(json); + return ParsePageMap(doc.RootElement); + } + + public static PageMap ParsePageMap(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + throw new FormatException("PageMap must be a JSON object"); + var pages = new List(); + if (root.TryGetProperty("pages", out var pagesElement)) + { + foreach (var page in pagesElement.EnumerateArray()) + pages.Add(new PageMapPage + { + PageNumber = page.GetProperty("pageNumber").GetInt32(), + PageInSection = page.TryGetProperty("pageInSection", out var pis) ? pis.GetInt32() : 1, + Width = page.GetProperty("width").GetDouble(), + Height = page.GetProperty("height").GetDouble(), + SectionIndex = page.TryGetProperty("sectionIndex", out var si) && si.ValueKind == JsonValueKind.Number + ? si.GetInt32() : null, + PageName = page.GetProperty("pageName").GetString() ?? string.Empty, + }); + } + var fragments = new List(); + if (root.TryGetProperty("fragments", out var fragmentsElement)) + { + foreach (var fragment in fragmentsElement.EnumerateArray()) + { + var geometry = fragment.GetProperty("geometry"); + fragments.Add(new PageMapFragment + { + FragmentId = fragment.GetProperty("fragmentId").GetString() ?? string.Empty, + AnchorId = fragment.GetProperty("anchorId").GetString() ?? string.Empty, + FragmentIndex = fragment.GetProperty("fragmentIndex").GetInt32(), + PageNumber = fragment.GetProperty("pageNumber").GetInt32(), + Geometry = new PageMapRect( + geometry.GetProperty("x").GetDouble(), + geometry.GetProperty("y").GetDouble(), + geometry.GetProperty("width").GetDouble(), + geometry.GetProperty("height").GetDouble()), + Story = ParsePageMapStory(fragment.GetProperty("story").GetString()), + InTableCell = fragment.TryGetProperty("inTableCell", out var itc) + && itc.ValueKind == JsonValueKind.True, + }); + } + } + return new PageMap + { + SchemaVersion = root.TryGetProperty("schemaVersion", out var sv) + ? sv.GetInt32() : PageMap.CurrentSchemaVersion, + Mode = ParsePageMapMode(root.GetProperty("mode").GetString()), + Availability = ParsePageMapAvailability(root.GetProperty("availability").GetString()), + DocumentVersion = root.GetProperty("documentVersion").GetInt64(), + RendererFingerprint = root.GetProperty("rendererFingerprint").GetString() ?? string.Empty, + Pages = pages, + Fragments = fragments, + }; + } + + public static PageCitationRequest? ParsePageCitationRequest(JsonElement root, string key = "citation") + { + if (!root.TryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.Object) + return null; + return new PageCitationRequest( + value.GetProperty("documentVersion").GetInt64(), + value.GetProperty("rendererFingerprint").GetString() ?? string.Empty); + } + + private static PageMapMode ParsePageMapMode(string? value) => value switch + { + "paginated" => PageMapMode.Paginated, + "continuous" => PageMapMode.Continuous, + _ => throw new FormatException($"Unknown PageMap mode: {value}"), + }; + + private static PageMapAvailability ParsePageMapAvailability(string? value) => value switch + { + "available" => PageMapAvailability.Available, + "unavailable" => PageMapAvailability.Unavailable, + _ => throw new FormatException($"Unknown PageMap availability: {value}"), + }; + + private static PageMapStory ParsePageMapStory(string? value) => value switch + { + "body" => PageMapStory.Body, + "header" => PageMapStory.Header, + "footer" => PageMapStory.Footer, + "footnote" => PageMapStory.Footnote, + "endnote" => PageMapStory.Endnote, + "comment" => PageMapStory.Comment, + _ => throw new FormatException($"Unknown PageMap story: {value}"), + }; + public static HeaderFooterKind ParseHeaderFooterKind(string? s) => (s?.ToLowerInvariant()) switch { @@ -412,6 +507,7 @@ public static TableRowHeightRule ParseTableRowHeightRule(string? rule) => KindFilter = TryGetString(root, "kindFilter", null), Scopes = scopes, ScopeFilter = TryGetString(root, "scopeFilter", null), + CitationRequest = ParsePageCitationRequest(root), }; } @@ -635,6 +731,85 @@ private static void AppendJsonValue(StringBuilder sb, object? value) public static string SerializeVersion(long version) => "{\"version\":" + version.ToString(System.Globalization.CultureInfo.InvariantCulture) + "}"; + public static string SerializePageMapRegistration(PageMapRegistrationResult result) + { + var sb = new StringBuilder("{\"success\":").Append(result.Success ? "true" : "false"); + if (result.Error is { } error) + sb.Append(",\"error\":\"").Append(EnumToSnake(error)).Append('"'); + if (result.Message is { } message) + sb.Append(",\"message\":").Append(JsonString(message)); + return sb.Append('}').ToString(); + } + + public static string SerializePageMapStatus(PageMapStatus status) + { + var sb = new StringBuilder("{\"availability\":") + .Append(JsonString(PageMapAvailabilityString(status.Availability))) + .Append(",\"documentVersion\":").Append(status.DocumentVersion); + if (status.UnavailableReason is { } reason) + sb.Append(",\"unavailableReason\":").Append(JsonString(EnumToSnake(reason))); + if (status.RendererFingerprint is { } fingerprint) + sb.Append(",\"rendererFingerprint\":").Append(JsonString(fingerprint)); + if (status.Mode is { } mode) + sb.Append(",\"mode\":").Append(JsonString(mode == PageMapMode.Paginated ? "paginated" : "continuous")); + return sb.Append('}').ToString(); + } + + public static string SerializePageCitation(PageCitation citation) + { + var sb = new StringBuilder(256); + AppendPageCitation(sb, citation); + return sb.ToString(); + } + + private static void AppendPageCitation(StringBuilder sb, PageCitation citation) + { + sb.Append("{\"anchorId\":").Append(JsonString(citation.AnchorId)) + .Append(",\"availability\":").Append(JsonString(PageMapAvailabilityString(citation.Availability))) + .Append(",\"documentVersion\":").Append(citation.DocumentVersion) + .Append(",\"rendererFingerprint\":").Append(JsonString(citation.RendererFingerprint)); + if (citation.UnavailableReason is { } reason) + sb.Append(",\"unavailableReason\":").Append(JsonString(EnumToSnake(reason))); + sb.Append(",\"fragments\":["); + for (int i = 0; i < citation.Fragments.Count; i++) + { + if (i > 0) sb.Append(','); + AppendPageMapFragment(sb, citation.Fragments[i]); + } + sb.Append("]}"); + } + + private static void AppendPageMapFragment(StringBuilder sb, PageMapFragment fragment) + { + sb.Append("{\"fragmentId\":").Append(JsonString(fragment.FragmentId)) + .Append(",\"anchorId\":").Append(JsonString(fragment.AnchorId)) + .Append(",\"fragmentIndex\":").Append(fragment.FragmentIndex) + .Append(",\"pageNumber\":").Append(fragment.PageNumber) + .Append(",\"geometry\":{\"x\":").Append(Invariant(fragment.Geometry.X)) + .Append(",\"y\":").Append(Invariant(fragment.Geometry.Y)) + .Append(",\"width\":").Append(Invariant(fragment.Geometry.Width)) + .Append(",\"height\":").Append(Invariant(fragment.Geometry.Height)).Append('}') + .Append(",\"story\":").Append(JsonString(PageMapStoryString(fragment.Story))) + .Append(",\"inTableCell\":").Append(fragment.InTableCell ? "true" : "false") + .Append('}'); + } + + private static string PageMapAvailabilityString(PageMapAvailability availability) => + availability == PageMapAvailability.Available ? "available" : "unavailable"; + + private static string PageMapStoryString(PageMapStory story) => story switch + { + PageMapStory.Header => "header", + PageMapStory.Footer => "footer", + PageMapStory.Footnote => "footnote", + PageMapStory.Endnote => "endnote", + PageMapStory.Comment => "comment", + _ => "body", + }; + + private static string Invariant(double value) => + value.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + public static string SerializeEditResults(IReadOnlyList results) { var sb = new StringBuilder(256); @@ -764,6 +939,11 @@ public static string SerializeMatches(IReadOnlyList matches) AppendStringArray(sb, m.Groups); sb.Append(",\"fragments\":"); AppendFragments(sb, m.Fragments); + if (m.Citation is { } citation) + { + sb.Append(",\"citation\":"); + AppendPageCitation(sb, citation); + } sb.Append('}'); } sb.Append(']'); @@ -804,6 +984,16 @@ public static string SerializeCrossBlockMatches(IReadOnlyList m .Append(",\"contextAfter\":").Append(JsonString(m.ContextAfter)) .Append(",\"groups\":"); AppendStringArray(sb, m.Groups); + if (m.Citations is { } citations) + { + sb.Append(",\"citations\":["); + for (int c = 0; c < citations.Count; c++) + { + if (c > 0) sb.Append(','); + AppendPageCitation(sb, citations[c]); + } + sb.Append(']'); + } sb.Append('}'); } sb.Append(']'); @@ -876,7 +1066,21 @@ public static string SerializeProjection(MarkdownProjection p) sb.Append(",\"autoNumberPrefix\":").Append(JsonString(prefix)); sb.Append('}'); } - sb.Append("}}"); + sb.Append('}'); + if (p.PageCitations is { } citations) + { + sb.Append(",\"pageCitations\":{"); + bool firstCitation = true; + foreach (var (anchorId, citation) in citations) + { + if (!firstCitation) sb.Append(','); + firstCitation = false; + sb.Append(JsonString(anchorId)).Append(':'); + AppendPageCitation(sb, citation); + } + sb.Append('}'); + } + sb.Append('}'); return sb.ToString(); } @@ -905,7 +1109,7 @@ public static string JsonString(string s) return sb.ToString(); } - public static string EnumToSnake(EditErrorCode code) + public static string EnumToSnake(System.Enum code) { var s = code.ToString(); var sb = new StringBuilder(s.Length + 4); @@ -1101,6 +1305,11 @@ public static void AppendAnchorTarget(StringBuilder sb, AnchorTarget t) .Append(",\"textPreview\":").Append(JsonString(t.TextPreview)); if (t.AutoNumberPrefix is { } prefix) sb.Append(",\"autoNumberPrefix\":").Append(JsonString(prefix)); + if (t.Citation is { } citation) + { + sb.Append(",\"citation\":"); + AppendPageCitation(sb, citation); + } sb.Append('}'); } diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs index 1d2e6cf4..328298e6 100644 --- a/Docxodus/Internal/DocxSessionOps.cs +++ b/Docxodus/Internal/DocxSessionOps.cs @@ -63,6 +63,18 @@ public static byte[] SaveWithAnchorIds(int handle) => public static string GetVersionJson(int handle) => DocxSessionJson.SerializeVersion(GetVersion(handle)); + public static string RegisterPageMap( + int handle, PageMap pageMap, string? expectedRendererFingerprint = null) => + DocxSessionJson.SerializePageMapRegistration( + SessionRegistry.Get(handle).RegisterPageMap(pageMap, expectedRendererFingerprint)); + + public static string GetPageMapStatus(int handle, PageCitationRequest? request = null) => + DocxSessionJson.SerializePageMapStatus(SessionRegistry.Get(handle).GetPageMapStatus(request)); + + public static string GetPageCitation(int handle, string anchorId, PageCitationRequest request) => + DocxSessionJson.SerializePageCitation( + SessionRegistry.Get(handle).GetPageCitation(anchorId, request)); + /// Read-only optimistic guard evaluation for dry runs and transport diagnostics. public static string CheckPreconditions(int handle, MutationPreconditions? preconditions) { @@ -104,8 +116,10 @@ public static string ListNotes(int handle, bool endnotes) => public static string ListAnchors(int handle) => DocxSessionJson.SerializeAnchorIndex(SessionRegistry.Get(handle).AnchorIndex()); - public static string ProjectAnchor(int handle, string anchorId, ProjectionDepth depth) => - DocxSessionJson.SerializeProjection(SessionRegistry.Get(handle).ProjectAnchor(anchorId, depth)); + public static string ProjectAnchor(int handle, string anchorId, ProjectionDepth depth, + PageCitationRequest? citationRequest = null) => + DocxSessionJson.SerializeProjection( + SessionRegistry.Get(handle).ProjectAnchor(anchorId, depth, citationRequest)); /// /// Render a single block from the live session to faithful HTML — the editor's @@ -171,28 +185,39 @@ public static string RenderHtml(int handle, string cssPrefix, bool fabricateClas }); public static string Grep(int handle, string pattern, RegexOptions regexOpts, - ProjectionScopes scope, int contextChars, WhitespaceMode whitespace, ContextBoundary boundary) => + ProjectionScopes scope, int contextChars, WhitespaceMode whitespace, ContextBoundary boundary, + PageCitationRequest? citationRequest = null) => DocxSessionJson.SerializeMatches( - SessionRegistry.Get(handle).Grep(pattern, regexOpts, scope, contextChars, whitespace, boundary)); + SessionRegistry.Get(handle).Grep( + pattern, regexOpts, scope, contextChars, whitespace, boundary, citationRequest)); public static string GrepCrossBlock(int handle, string pattern, RegexOptions regexOpts, - ProjectionScopes scope, int contextChars, WhitespaceMode whitespace, ContextBoundary boundary) => + ProjectionScopes scope, int contextChars, WhitespaceMode whitespace, ContextBoundary boundary, + PageCitationRequest? citationRequest = null) => DocxSessionJson.SerializeCrossBlockMatches( - SessionRegistry.Get(handle).GrepCrossBlock(pattern, regexOpts, scope, contextChars, whitespace, boundary)); + SessionRegistry.Get(handle).GrepCrossBlock( + pattern, regexOpts, scope, contextChars, whitespace, boundary, citationRequest)); public static string FindPlaceholders(int handle, PlaceholderKinds kinds, ProjectionScopes scope, - int contextChars, ContextBoundary boundary) => + int contextChars, ContextBoundary boundary, PageCitationRequest? citationRequest = null) => DocxSessionJson.SerializePlaceholders( - SessionRegistry.Get(handle).FindPlaceholders(kinds, scope, contextChars, boundary)); + SessionRegistry.Get(handle).FindPlaceholders( + kinds, scope, contextChars, boundary, citationRequest)); - public static string FindByAnnotation(int handle, string annotationId) => - DocxSessionJson.SerializeAnchorTargets(SessionRegistry.Get(handle).FindByAnnotation(annotationId)); + public static string FindByAnnotation( + int handle, string annotationId, PageCitationRequest? citationRequest = null) => + DocxSessionJson.SerializeAnchorTargets( + SessionRegistry.Get(handle).FindByAnnotation(annotationId, citationRequest)); - public static string FindByLabel(int handle, string labelId) => - DocxSessionJson.SerializeAnchorTargetMap(SessionRegistry.Get(handle).FindByLabel(labelId)); + public static string FindByLabel( + int handle, string labelId, PageCitationRequest? citationRequest = null) => + DocxSessionJson.SerializeAnchorTargetMap( + SessionRegistry.Get(handle).FindByLabel(labelId, citationRequest)); - public static string FindByBookmark(int handle, string bookmarkName) => - DocxSessionJson.SerializeAnchorTargets(SessionRegistry.Get(handle).FindByBookmark(bookmarkName)); + public static string FindByBookmark( + int handle, string bookmarkName, PageCitationRequest? citationRequest = null) => + DocxSessionJson.SerializeAnchorTargets( + SessionRegistry.Get(handle).FindByBookmark(bookmarkName, citationRequest)); public static string ListAnnotations(int handle) => DocxSessionJson.SerializeAnnotations(SessionRegistry.Get(handle).ListAnnotations()); @@ -227,8 +252,10 @@ public static string FindAllByText(int handle, string needle, FindOptions? optio public static string FindByRegex(int handle, string pattern, RegexOptions regexOptions, FindOptions? options) => DocxSessionJson.SerializeAnchorTargets(SessionRegistry.Get(handle).FindByRegex(pattern, regexOptions, options)); - public static string FindByKind(int handle, string kind, string? scope) => - DocxSessionJson.SerializeAnchorTargets(SessionRegistry.Get(handle).FindByKind(kind, scope)); + public static string FindByKind( + int handle, string kind, string? scope, PageCitationRequest? citationRequest = null) => + DocxSessionJson.SerializeAnchorTargets( + SessionRegistry.Get(handle).FindByKind(kind, scope, citationRequest)); public static string GetEditSummary(int handle) => DocxSessionJson.SerializeEditSummary(SessionRegistry.Get(handle).GetEditSummary()); diff --git a/Docxodus/Internal/HtmlConversionOps.cs b/Docxodus/Internal/HtmlConversionOps.cs index b2228fb1..10afe27b 100644 --- a/Docxodus/Internal/HtmlConversionOps.cs +++ b/Docxodus/Internal/HtmlConversionOps.cs @@ -61,21 +61,10 @@ internal static class HtmlConversionOps /// data-anchor stamps exactly like body paragraphs — without them a rendered footnote is /// visible but not addressable, so an editor can show it and not edit it. /// - /// - /// Header/footer parts are deliberately NOT stamped here. Paginated output clones one header - /// node onto every page, so a stamped header anchor would exist N times in the DOM; the editor's - /// header/footer bands compose per story paragraph through - /// instead. Footnotes have no such problem — each note renders exactly once. - /// private static void AssignAnchorUnids(WordprocessingDocument doc) { - var main = doc.MainDocumentPart; - if (main is null) return; - UnidHelper.AssignToAllElementsDeterministic(main.GetXDocument().Root!); - if (main.FootnotesPart?.GetXDocument().Root is { } fn) - UnidHelper.AssignToAllElementsDeterministic(fn); - if (main.EndnotesPart?.GetXDocument().Root is { } en) - UnidHelper.AssignToAllElementsDeterministic(en); + _ = WmlToMarkdownConverter.BuildAnchorIndexOnly(doc, + new WmlToMarkdownConverterSettings { Scopes = ProjectionScopes.All }); } /// Render raw DOCX bytes to a self-contained HTML string. @@ -143,6 +132,7 @@ public static string ConvertToHtml(byte[] docxBytes, HtmlConversionOptions optio IncludeUnsupportedContentMetadata = true, DocumentLanguage = options.DocumentLanguage, StampAnchors = options.StampAnchors, + StampCanonicalSourceAnchors = options.StampAnchors, // Embed images as base64 data URIs — no SkiaSharp needed (WASM-safe). ImageHandler = CreateBase64ImageHandler(), }; diff --git a/Docxodus/PageMap.cs b/Docxodus/PageMap.cs new file mode 100644 index 00000000..2087d638 --- /dev/null +++ b/Docxodus/PageMap.cs @@ -0,0 +1,141 @@ +#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; + +namespace Docxodus; + +/// The layout substrate that produced a . +public enum PageMapMode +{ + /// Fixed page boxes exist and geometry is authoritative. + Paginated, + + /// No fixed pages exist. Citations are deliberately unavailable. + Continuous, +} + +/// Whether a materialized map can answer page-citation requests. +public enum PageMapAvailability +{ + Unavailable, + Available, +} + +/// The OOXML story that owns a rendered anchor fragment. +public enum PageMapStory +{ + Body, + Header, + Footer, + Footnote, + Endnote, + Comment, +} + +/// A rectangle in page-relative points, with the page's top-left as (0, 0). +public sealed record PageMapRect(double X, double Y, double Width, double Height); + +/// Physical page-box geometry. Page numbers are 1-based and document-global. +public sealed record PageMapPage +{ + required public int PageNumber { get; init; } + required public int PageInSection { get; init; } + required public double Width { get; init; } + required public double Height { get; init; } + public int? SectionIndex { get; init; } + /// The renderer's stable page-style identity (for example a named CSS @page rule). + required public string PageName { get; init; } +} + +/// +/// One visible piece of one source anchor. A paragraph, table, or note split across pages has +/// multiple fragments with the same and distinct page-qualified +/// values. +/// +public sealed record PageMapFragment +{ + required public string FragmentId { get; init; } + required public string AnchorId { get; init; } + required public int FragmentIndex { get; init; } + required public int PageNumber { get; init; } + required public PageMapRect Geometry { get; init; } + required public PageMapStory Story { get; init; } + + /// True when the fragment belongs to an addressable table cell. + public bool InTableCell { get; init; } +} + +/// +/// Portable output of a paginated renderer. Core Docxodus validates and consumes this contract; +/// it never invents page numbers. Version 1 uses page-relative point geometry. +/// +public sealed record PageMap +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + required public PageMapMode Mode { get; init; } + required public PageMapAvailability Availability { get; init; } + required public long DocumentVersion { get; init; } + required public string RendererFingerprint { get; init; } + public IReadOnlyList Pages { get; init; } = Array.Empty(); + public IReadOnlyList Fragments { get; init; } = Array.Empty(); +} + +/// Why a PageMap registration was rejected. +public enum PageMapRegistrationError +{ + UnsupportedSchemaVersion, + StaleDocumentVersion, + RendererFingerprintMismatch, + InvalidMap, +} + +/// Typed result of . +public sealed record PageMapRegistrationResult +{ + required public bool Success { get; init; } + public PageMapRegistrationError? Error { get; init; } + public string? Message { get; init; } +} + +/// +/// Identifies the exact rendered layout a read wants citations from. Both fields are mandatory: +/// accepting "the latest" map would make concurrent render/edit workflows ambiguous. +/// +public sealed record PageCitationRequest(long DocumentVersion, string RendererFingerprint); + +/// Why an optional page citation could not be supplied. +public enum PageCitationUnavailableReason +{ + NoPageMap, + ContinuousMode, + StaleDocumentVersion, + RendererFingerprintMismatch, + AnchorNotMapped, +} + +/// Explicit citation result for one source anchor. +public sealed record PageCitation +{ + required public string AnchorId { get; init; } + required public PageMapAvailability Availability { get; init; } + public PageCitationUnavailableReason? UnavailableReason { get; init; } + required public long DocumentVersion { get; init; } + required public string RendererFingerprint { get; init; } + public IReadOnlyList Fragments { get; init; } = Array.Empty(); +} + +/// Session-level state of the registered layout map. +public sealed record PageMapStatus +{ + required public PageMapAvailability Availability { get; init; } + public PageCitationUnavailableReason? UnavailableReason { get; init; } + required public long DocumentVersion { get; init; } + public string? RendererFingerprint { get; init; } + public PageMapMode? Mode { get; init; } +} diff --git a/Docxodus/WmlToHtmlConverter.cs b/Docxodus/WmlToHtmlConverter.cs index cc86719b..d472feed 100644 --- a/Docxodus/WmlToHtmlConverter.cs +++ b/Docxodus/WmlToHtmlConverter.cs @@ -273,6 +273,20 @@ public class WmlToHtmlConverterSettings /// public bool StampAnchors; + /// + /// Optional canonical anchor identity provider used alongside . + /// The legacy data-anchor value is intentionally the bare Unid for editor + /// compatibility; this provider stamps the collision-safe full + /// kind:scope:unid value as data-source-anchor-id for pagination/PageMap. + /// + internal Func? SourceAnchorIdentityProvider; + + /// Build from the converter's + /// post-simplification trees immediately before the HTML transform. This timing matters: + /// MarkupSimplifier and FormattingAssembler replace part roots, invalidating any earlier + /// XElement-reference map. + internal bool StampCanonicalSourceAnchors; + /// /// Skip MarkupSimplifier's pass over the style-definition parts (styles + stylesWithEffects). /// That pass only strips rendering-irrelevant metadata (rsids; styles carry no body runs / @@ -622,6 +636,7 @@ public class CommentInfo public string Author { get; set; } public string Date { get; set; } public string Initials { get; set; } + internal XElement SourceElement { get; set; } public List ContentParagraphs { get; set; } = new List(); } @@ -817,6 +832,10 @@ public class DocumentMetadata /// Estimated total page count (rough estimate based on content) public int EstimatedPageCount { get; set; } + + /// Always "heuristic". Authoritative counts come only from a + /// browser-materialized PageMap. + public string EstimatedPageCountSource { get; set; } = "heuristic"; } public static partial class WmlToHtmlConverter @@ -968,6 +987,34 @@ public static XElement ConvertToHtml(WordprocessingDocument wordDoc, WmlToHtmlCo } rootElement.AddAnnotation(footnoteTracker); + if (htmlConverterSettings.StampAnchors + && htmlConverterSettings.StampCanonicalSourceAnchors) + { + // Build from the FINAL source trees. The next operation is the HTML transform, so + // these reference keys cannot be invalidated by another preprocessing rewrite. + var canonicalIndex = WmlToMarkdownConverter.BuildAnchorIndexOnly(wordDoc, + new WmlToMarkdownConverterSettings { Scopes = ProjectionScopes.All }); + var canonicalByElement = new Dictionary(); + var canonicalByLocation = new Dictionary<(string PartUri, string Kind, string Unid), string>(); + foreach (var target in canonicalIndex.Values) + { + var source = target.Resolve(wordDoc); + if (source != null) canonicalByElement[source] = target.Anchor.Id; + canonicalByLocation[(target.PartUri, target.Anchor.Kind, target.Unid)] = target.Anchor.Id; + } + htmlConverterSettings.SourceAnchorIdentityProvider = element => + { + if (canonicalByElement.TryGetValue(element, out var id)) return id; + var kind = WmlToMarkdownConverter.KindFor(element); + var unid = (string?)element.Attribute(PtOpenXml.Unid); + var partUri = element.Document?.Root?.Annotation()?.Uri.ToString(); + return kind != null && unid != null && partUri != null + && canonicalByLocation.TryGetValue((partUri, kind, unid), out id) + ? id + : null; + }; + } + XElement xhtml = (XElement)ConvertToHtmlTransform(wordDoc, htmlConverterSettings, rootElement, false, 0m); @@ -3580,6 +3627,7 @@ private static XElement RenderFootnoteItem(WordprocessingDocument wordDoc, var li = new XElement(Xhtml.li, new XAttribute("id", $"{noteType}-{noteId}"), new XAttribute("value", displayNumber), + SourceAnchorIdentityAttribute(settings, noteElement), content); // If no paragraph found, append backref directly to li (fallback) @@ -3662,6 +3710,7 @@ private static XElement RenderPaginatedFootnoteRegistry(WordprocessingDocument w new XAttribute("data-footnote-id", footnoteId), new XAttribute("data-display-number", displayNumber), new XAttribute("class", "footnote-item"), + SourceAnchorIdentityAttribute(settings, fn), new XElement(Xhtml.span, new XAttribute("class", "footnote-number"), new XText(displayNumber)), @@ -4042,6 +4091,7 @@ private static void LoadComments(WordprocessingDocument wordDoc, CommentTracker Author = (string)comment.Attribute(W.author), Date = (string)comment.Attribute(W.date), Initials = (string)comment.Attribute(W.initials), + SourceElement = comment, ContentParagraphs = comment.Elements(W.p).ToList() }; } @@ -4272,7 +4322,8 @@ private static XElement RenderCommentItem(WordprocessingDocument wordDoc, { var li = new XElement(Xhtml.li, new XAttribute("id", $"comment-{comment.Id}"), - new XAttribute("class", prefix.TrimEnd('-'))); + new XAttribute("class", prefix.TrimEnd('-')), + SourceAnchorIdentityAttribute(settings, comment.SourceElement)); if (settings.IncludeCommentMetadata) { @@ -4325,7 +4376,8 @@ private static XElement RenderCommentItem(WordprocessingDocument wordDoc, if (!string.IsNullOrWhiteSpace(textContent)) { - body.Add(new XElement(Xhtml.p, textContent)); + body.Add(new XElement(Xhtml.p, + SourceAnchorIdentityAttribute(settings, para), textContent)); } } @@ -4377,7 +4429,8 @@ private static XElement RenderMarginCommentNote(WmlToHtmlConverterSettings setti var note = new XElement(Xhtml.div, new XAttribute("id", $"comment-{comment.Id}"), new XAttribute("class", prefix + "margin-note"), - new XAttribute("data-comment-id", comment.Id.ToString())); + new XAttribute("data-comment-id", comment.Id.ToString()), + SourceAnchorIdentityAttribute(settings, comment.SourceElement)); if (settings.IncludeCommentMetadata) { @@ -4430,7 +4483,8 @@ private static XElement RenderMarginCommentNote(WmlToHtmlConverterSettings setti if (!string.IsNullOrWhiteSpace(textContent)) { - body.Add(new XElement(Xhtml.p, textContent)); + body.Add(new XElement(Xhtml.p, + SourceAnchorIdentityAttribute(settings, para), textContent)); } } @@ -4901,6 +4955,7 @@ private static object ProcessTable(WordprocessingDocument wordDoc, WmlToHtmlConv settings.StampAnchors && (string)element.Attribute(PtOpenXml.Unid) != null ? new XAttribute("data-anchor", (string)element.Attribute(PtOpenXml.Unid)) : null, + SourceAnchorIdentityAttribute(settings, element), CreateColGroup(element), element.Elements().Select(e => ConvertToHtmlTransform(wordDoc, settings, e, false, currentMarginLeft))); table.AddAnnotation(style); @@ -5106,6 +5161,7 @@ private static object ProcessTableCell(WordprocessingDocument wordDoc, WmlToHtml settings.StampAnchors && (string)element.Attribute(PtOpenXml.Unid) != null ? new XAttribute("data-anchor", (string)element.Attribute(PtOpenXml.Unid)) : null, + SourceAnchorIdentityAttribute(settings, element), CreateBorderDivs(wordDoc, settings, element.Elements())); cell.AddAnnotation(style); @@ -5175,6 +5231,7 @@ private static object ProcessTableRow(WordprocessingDocument wordDoc, WmlToHtmlC style.AddIfMissing("height", string.Format(NumberFormatInfo.InvariantInfo, "{0:0.00}in", (decimal) trHeight/1440m)); var htmlRow = new XElement(Xhtml.tr, + SourceAnchorIdentityAttribute(settings, element), element.Elements().Select(e => ConvertToHtmlTransform(wordDoc, settings, e, false, currentMarginLeft))); if (style.Any()) htmlRow.AddAnnotation(style); @@ -5471,6 +5528,15 @@ private enum BorderType * */ + private static XAttribute? SourceAnchorIdentityAttribute( + WmlToHtmlConverterSettings settings, XElement source) + { + if (!settings.StampAnchors || settings.SourceAnchorIdentityProvider == null) + return null; + var id = settings.SourceAnchorIdentityProvider(source); + return string.IsNullOrEmpty(id) ? null : new XAttribute("data-source-anchor-id", id); + } + private static object ConvertParagraph(WordprocessingDocument wordDoc, WmlToHtmlConverterSettings settings, XElement paragraph, XName elementName, bool suppressTrailingWhiteSpace, decimal currentMarginLeft, bool isBidi, bool suppressLeadingWhiteSpace = false) @@ -5481,6 +5547,7 @@ private static object ConvertParagraph(WordprocessingDocument wordDoc, WmlToHtml var anchorAttr = settings.StampAnchors && (string)paragraph.Attribute(PtOpenXml.Unid) != null ? new XAttribute("data-anchor", (string)paragraph.Attribute(PtOpenXml.Unid)) : null; + var sourceAnchorAttr = SourceAnchorIdentityAttribute(settings, paragraph); // Analyze initial runs to see whether we have a tab, in which case we will render // a span with a defined width and ignore the tab rather than rendering the text @@ -5514,6 +5581,7 @@ private static object ConvertParagraph(WordprocessingDocument wordDoc, WmlToHtml rtl, firstMark, anchorAttr, + sourceAnchorAttr, ConvertContentThatCanContainFields(wordDoc, settings, paragraph.Elements())); ApplyAutomaticLineSpacingToInlineContent(paraElement1, style); paraElement1.AddAnnotation(style); @@ -5528,6 +5596,7 @@ private static object ConvertParagraph(WordprocessingDocument wordDoc, WmlToHtml rtl, firstMark, anchorAttr, + sourceAnchorAttr, txElementsPrecedingTab, ConvertContentThatCanContainFields(wordDoc, settings, elementsSucceedingTab)); ApplyAutomaticLineSpacingToInlineContent(paraElement, style); @@ -6103,6 +6172,9 @@ private static object ConvertRun(WordprocessingDocument wordDoc, WmlToHtmlConver { if (tracker.Comments.TryGetValue(commentId, out var comment)) { + highlightSpan.Add(SourceAnchorIdentityAttribute( + settings, comment.SourceElement)); + // Build inline tooltip var tooltipText = comment.ContentParagraphs .SelectMany(p => p.Descendants(W.t) diff --git a/Docxodus/WmlToMarkdownConverter.cs b/Docxodus/WmlToMarkdownConverter.cs index 1525d22e..dd054e88 100644 --- a/Docxodus/WmlToMarkdownConverter.cs +++ b/Docxodus/WmlToMarkdownConverter.cs @@ -221,6 +221,9 @@ public sealed class AnchorTarget /// public string? AutoNumberPrefix { get; init; } + /// Null unless the discovery call requested an exact page citation. + public PageCitation? Citation { get; init; } + /// /// The element's text as a reader would see it: /// joined with by a single space when a prefix is @@ -272,6 +275,12 @@ public sealed class MarkdownProjection { required public string Markdown { get; init; } required public IReadOnlyDictionary AnchorIndex { get; init; } + + /// + /// Optional page citations keyed by canonical anchor id. Null when citations were not + /// requested; requested-but-unavailable entries carry an explicit reason. + /// + public IReadOnlyDictionary? PageCitations { get; init; } } public partial class WmlDocument diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md index e71c0e75..2f276b5e 100644 --- a/docs/architecture/docx_agent_server.md +++ b/docs/architecture/docx_agent_server.md @@ -27,8 +27,8 @@ facade the WASM bridge and the Python stdio host use. No new editing logic lives Document-editing MCP servers built around "open a file into a stateful in-memory session, address every subsequent edit by a stable anchor id, group many operations under a handful of -grouped-intent tools (read / search / edit / format / create / list / comment / annotate / -track-changes / batch-mutate / table), save on request" are a known-good shape for this problem — it matches how +grouped-intent tools (read / preview / pagination / search / edit / format / create / list / +comment / annotate / track-changes / batch-mutate / table), save on request" are a known-good shape for this problem — it matches how this class of tool is used in practice: an agent reads a projection once, holds anchor ids in its context, and issues a sequence of small, anchor-addressed mutations before saving. This server adopts that shape but is a clean-room implementation against Docxodus's own `DocxSession` engine @@ -85,7 +85,7 @@ session registry assumes single-threaded access. — the requested `protocolVersion` is echoed when present (every implemented method is shape-stable across published revisions; the UI extension negotiates via `capabilities.extensions`) - `notifications/initialized` → no response (notification) -- `tools/list` → `{ tools: [ { name, description, inputSchema, _meta? }, ... ] }` — the 15 tools below +- `tools/list` → `{ tools: [ { name, description, inputSchema, _meta? }, ... ] }` — the 16 tools below - `tools/call` params `{ name, arguments }` → `{ content: [ { type: "text", text: } ], isError }` (plus `structuredContent`/`_meta` on the two preview-related tools — see "Inline preview" below) - `resources/list` / `resources/read` / `resources/templates/list` — serve the `ui://` viewer @@ -218,7 +218,7 @@ problem that has no good answer at this layer. ## Tool reference -Three lifecycle tools, eleven grouped-intent tools. Every grouped tool takes `sessionId` plus an +Three lifecycle tools, thirteen grouped-intent tools. Every grouped tool takes `sessionId` plus an `action` string; see `tools/mcp-server/ToolCatalog.cs` for the exact JSON Schema advertised over `tools/list` (this section is the narrative version). @@ -256,6 +256,19 @@ Apps hosts deliver to the widget (`ui/notifications/tool-result`) and ChatGPT ex context nothing. Call it again after edits to refresh the view; the widget's Refresh button does exactly that via widget-initiated `tools/call`. See "Inline preview" below. +This preview is continuous until #434 provides a server-side paginated HTML substrate. With a +valid citation token it displays the exact cited page label and highlights the source anchor, but +returns `pageNavigation: "unavailable_continuous_preview"` rather than pretending a physical page +box exists. + +### `docxodus_pagination` — register or consume an exact PageMap + +`action: register` validates a renderer-materialized `PageMap`; `status` and `cite` consume it +with an exact `{ documentVersion, rendererFingerprint }` token. Mutations stale the map +automatically. Continuous/no-map, stale, fingerprint-mismatched, and unmapped-anchor results are +explicitly unavailable. The server does not estimate pages or bundle a browser; see +[`page_map.md`](page_map.md). + ### `docxodus_search` — find text or blocks, get reusable anchor ids back `mode: text` and `regex` use `DocxSessionOps.Grep` (returns span + context, not just an anchor — @@ -268,6 +281,7 @@ categories (`headers` covers every `hdr*`, not merely `hdr1`). Text/regex result reusable id at `enclosingAnchor.id`; anchor-only results carry it at `id`. Either is the same anchor every other tool's `anchorId`/`cellAnchorId` argument expects — this server doesn't invent a separate "search result handle" concept; Docxodus's anchors already are one. +All search modes accept the exact citation token and attach a citation envelope to each result. ### `docxodus_edit` — text/block CRUD + undo/redo diff --git a/docs/architecture/page_map.md b/docs/architecture/page_map.md new file mode 100644 index 00000000..9e725d28 --- /dev/null +++ b/docs/architecture/page_map.md @@ -0,0 +1,107 @@ +# Portable PageMap and page citations + +`PageMap` is the versioned handoff between a renderer that has performed physical pagination and +the stateful `DocxSession` APIs that answer search and citation requests. It deliberately does not +put pagination into core Docxodus: the browser `PaginationEngine` is currently the page authority, +and issue #434 remains the dependency for a server-side paginated HTML substrate. + +## Authority and availability + +A renderer may publish an available map only after it has created fixed page boxes and measured +every rendered, addressable source node. Continuous HTML has no physical page substrate and must +use an unavailable map: + +```json +{ + "schemaVersion": 1, + "mode": "continuous", + "availability": "unavailable", + "documentVersion": 12, + "rendererFingerprint": "continuous-preview-v1", + "pages": [], + "fragments": [] +} +``` + +No surface derives page numbers from document length, section metadata, or +`EstimatedPageCount`. That metadata field is explicitly labeled `heuristic`; it is not a citation +source. + +## Version 1 contract + +An available paginated map contains: + +- the exact `documentVersion` from the session that produced the rendered HTML; +- a caller-defined `rendererFingerprint` identifying every layout-affecting renderer input; +- document-ordered physical pages, including global `pageNumber`, `pageInSection`, dimensions in + points, optional non-negative `sectionIndex`, and stable `pageName`; +- one fragment for every visible piece of every rendered source anchor, with a page-qualified + `fragmentId`, full canonical `kind:scope:unid` `anchorId`, contiguous `fragmentIndex`, page-relative + point geometry, owning story (`body`, `header`, `footer`, `footnote`, `endnote`, or `comment`), and + table-cell ownership. + +Geometry is normalized against the known page box, so it is independent of CSS pixels, browser +zoom, and `PaginationOptions.scale`. + +The browser producer inventories addressable source IDs before pagination moves content out of +staging. It separately inventories cited footnote definitions and the header/footer variants +selected by real pages. Publishing fails if any expected canonical source lacks a measurable page +fragment. Producers can mark deliberately non-rendered content with +`data-page-map-exclude="true"`; `hidden`, `aria-hidden="true"`, and inline hidden styles carry the +same meaning. + +## Source identity and clones + +`data-anchor` remains the bare Unid used by the editor, and an active bare value is unique across +the paginated DOM. If package stories reuse a bare Unid, only one legacy editor anchor remains +active; every source still retains its collision-safe canonical identity. Bare Unids are never +PageMap keys. + +Every rendered addressable block instead carries `data-source-anchor-id="kind:scope:unid"`. +Paragraphs, headings, lists, tables, rows, cells, note definitions and their paragraphs, and +visible comment representations are covered. The converter rebuilds this identity map from its +final preprocessed `XElement` trees immediately before HTML transformation. + +Pagination clones retain `data-source-anchor-id` and receive `data-page-number`, +`data-fragment-index`, and `data-page-fragment-id`. Continuations and repeated headers, footers, +and notes are presentation fragments: they do not duplicate the active `data-anchor`. + +## Registration and invalidation + +Register a map through `DocxSession.RegisterPageMap` (`registerPageMap` in TypeScript, +`register_page_map` in Python, or `docxodus_pagination` over MCP). Registration validates the schema, +enum discriminators, exact document version and optional expected fingerprint, page/section order, +canonical live anchors, story/scope and table ancestry, page bounds, and fragment chronology. + +A successful mutation, undo, or redo advances the session version and immediately makes the map +stale. Citation reads require an exact `{documentVersion, rendererFingerprint}` token. Their +unavailable result is typed as one of `no_page_map`, `continuous_mode`, +`stale_document_version`, `renderer_fingerprint_mismatch`, or `anchor_not_mapped`. + +## Browser workflow + +```ts +const version = session.getVersion(); +const rendererFingerprint = 'chromium-140|fonts-v3|docxodus-pagination-v1'; + +const result = paginateHtml(html, viewer, { + layoutToken: { documentVersion: version, rendererFingerprint }, +}); +const registration = session.registerPageMap(result.pageMap!, rendererFingerprint); +if (!registration.success) throw new Error(registration.message); + +const citation = session.getPageCitation(anchorId, { + documentVersion: version, + rendererFingerprint, +}); +navigateToPageCitation(viewer, citation); +``` + +`PaginatedDocument` accepts the same `layoutToken` and an optional `citation`; it navigates and +highlights after pagination. Search and scoped projection APIs accept the exact citation token and +attach citation envelopes without changing their results when citations are not requested. + +The current MCP inline widget renders continuous HTML, so it can display the cited page label and +highlight an anchor but reports `pageNavigation: "unavailable_continuous_preview"`. Once #434 +provides a physical server-rendered substrate, the same PageMap and navigation contract can consume +it without inventing a second page model. diff --git a/npm/README.md b/npm/README.md index 6acb3d16..9cee148f 100644 --- a/npm/README.md +++ b/npm/README.md @@ -33,6 +33,23 @@ numbering, tables, images, comments, headers and footers, and real footnotes wit Paginated mode flows content into real page boxes with per-page numbers and page-anchored footnotes — a print-accurate preview in the browser. +Supply an exact layout token to materialize portable page citations from that layout: + +```ts +const result = paginateHtml(html, viewer, { + layoutToken: { documentVersion: session.getVersion(), rendererFingerprint: 'chromium-layout-v1' }, +}); +session.registerPageMap(result.pageMap!, 'chromium-layout-v1'); +const citation = session.getPageCitation(anchorId, { + documentVersion: session.getVersion(), + rendererFingerprint: 'chromium-layout-v1', +}); +navigateToPageCitation(viewer, citation); +``` + +Continuous renderers return explicit unavailability; Docxodus never estimates a citation page. +See the [PageMap contract](../docs/architecture/page_map.md). + ## Edit it in place `DocxEditor` is a framework-agnostic block editor over the live document. Edits go through a diff --git a/npm/src/docxodus.worker.ts b/npm/src/docxodus.worker.ts index ff534f72..eea52139 100644 --- a/npm/src/docxodus.worker.ts +++ b/npm/src/docxodus.worker.ts @@ -394,6 +394,7 @@ function handleGetDocumentMetadata( hasTrackedChanges: parsed.HasTrackedChanges ?? parsed.hasTrackedChanges, hasComments: parsed.HasComments ?? parsed.hasComments, estimatedPageCount: parsed.EstimatedPageCount ?? parsed.estimatedPageCount, + estimatedPageCountSource: parsed.EstimatedPageCountSource ?? parsed.estimatedPageCountSource ?? "heuristic", }; return { metadata }; diff --git a/npm/src/index.ts b/npm/src/index.ts index 1f5acbbf..fadfcacc 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -171,9 +171,22 @@ export type { PageInfo, PaginationResult, PaginationOptions, + PageMap, + PageMapPage, + PageMapFragment, + PageMapRect, + PageMapMode, + PageMapAvailability, + PageMapStory, + PageCitationNavigation, } from "./pagination.js"; -export { PaginationEngine, paginateHtml } from "./pagination.js"; +export { + PaginationEngine, + createUnavailablePageMap, + navigateToPageCitation, + paginateHtml, +} from "./pagination.js"; // Page geometry is the document's own page setup (w:sectPr), read off the section wrappers // the converter stamps in every render mode, plus the fit-to-width zoom a view applies to it. @@ -1786,6 +1799,7 @@ export async function getDocumentMetadata( hasTrackedChanges: parsed.HasTrackedChanges ?? parsed.hasTrackedChanges, hasComments: parsed.HasComments ?? parsed.hasComments, estimatedPageCount: parsed.EstimatedPageCount ?? parsed.estimatedPageCount, + estimatedPageCountSource: parsed.EstimatedPageCountSource ?? parsed.estimatedPageCountSource ?? "heuristic", }; } diff --git a/npm/src/pagination.ts b/npm/src/pagination.ts index 4c1678fe..fd7ad2f5 100644 --- a/npm/src/pagination.ts +++ b/npm/src/pagination.ts @@ -118,6 +118,142 @@ export interface PaginationResult { totalPages: number; /** Array of page information */ pages: PageInfo[]; + /** Present only when the caller supplied an exact layoutToken. */ + pageMap?: PageMap; +} + +export type PageMapMode = "paginated" | "continuous"; +export type PageMapAvailability = "available" | "unavailable"; +export type PageMapStory = "body" | "header" | "footer" | "footnote" | "endnote" | "comment"; + +export interface PageMapRect { + /** Page-relative points, independent of viewer zoom/transform. */ + x: number; + y: number; + width: number; + height: number; +} + +export interface PageMapPage { + pageNumber: number; + pageInSection: number; + width: number; + height: number; + sectionIndex?: number; + pageName: string; +} + +export interface PageMapFragment { + fragmentId: string; + /** Canonical collision-safe `kind:scope:unid`, never the bare editor Unid. */ + anchorId: string; + fragmentIndex: number; + pageNumber: number; + geometry: PageMapRect; + story: PageMapStory; + /** Table-cell ownership is orthogonal to story (e.g. a body or footnote table cell). */ + inTableCell: boolean; +} + +/** Versioned portable layout contract consumed by DocxSession and remote agent surfaces. */ +export interface PageMap { + schemaVersion: 1; + mode: PageMapMode; + availability: PageMapAvailability; + documentVersion: number; + rendererFingerprint: string; + pages: PageMapPage[]; + fragments: PageMapFragment[]; +} + +/** Explicit no-pages contract for a continuous viewer. It never estimates page numbers. */ +export function createUnavailablePageMap( + documentVersion: number, + rendererFingerprint: string, + mode: "continuous" = "continuous", +): PageMap { + if (!rendererFingerprint) throw new Error("rendererFingerprint must be non-empty"); + return { + schemaVersion: 1, + mode, + availability: "unavailable", + documentVersion, + rendererFingerprint, + pages: [], + fragments: [], + }; +} + +export interface PageCitationNavigation { + navigated: boolean; + target?: HTMLElement; + pageNumber?: number; + fragmentId?: string; + unavailableReason?: "citation_unavailable" | "fragment_not_found"; +} + +/** + * Navigate an exact citation over an already-paginated DOM. The page-qualified fragment id is + * authoritative; page + canonical source identity is a compatibility fallback for older v1 DOMs. + */ +export function navigateToPageCitation( + root: ParentNode, + citation: { + availability: PageMapAvailability; + anchorId: string; + fragments: Array<{ fragmentId: string; pageNumber: number }>; + }, + options: { + highlightClass?: string; + /** Apply a visible inline highlight when no class is supplied. Default true. */ + highlight?: boolean; + behavior?: ScrollBehavior; + block?: ScrollLogicalPosition; + } = {}, +): PageCitationNavigation { + if (citation.availability !== "available" || citation.fragments.length === 0) { + return { navigated: false, unavailableReason: "citation_unavailable" }; + } + + const byAttribute = (name: string, value: string, within: ParentNode = root): HTMLElement | null => { + for (const node of Array.from(within.querySelectorAll(`[${name}]`))) { + if (node.getAttribute(name) === value) return node; + } + return null; + }; + + const fragment = citation.fragments[0]; + let target = byAttribute("data-page-fragment-id", fragment.fragmentId); + if (!target) { + const page = byAttribute("data-page-number", String(fragment.pageNumber)); + if (page) target = byAttribute("data-source-anchor-id", citation.anchorId, page) ?? page; + } + if (!target) { + return { + navigated: false, + pageNumber: fragment.pageNumber, + fragmentId: fragment.fragmentId, + unavailableReason: "fragment_not_found", + }; + } + + if (options.highlightClass) { + target.classList.add(options.highlightClass); + } else if (options.highlight !== false) { + target.style.setProperty("outline", "3px solid #f4b400", "important"); + target.style.setProperty("outline-offset", "2px", "important"); + target.style.setProperty("background-color", "rgba(255, 235, 59, .18)", "important"); + } + target.scrollIntoView({ + behavior: options.behavior ?? "smooth", + block: options.block ?? "center", + }); + return { + navigated: true, + target, + pageNumber: fragment.pageNumber, + fragmentId: fragment.fragmentId, + }; } /** @@ -138,6 +274,8 @@ export interface PaginationOptions { * entry points opt in explicitly. */ fragmentParagraphs?: boolean; + /** Exact invalidation tokens used to materialize an authoritative PageMap with the result. */ + layoutToken?: { documentVersion: number; rendererFingerprint: string }; } // Default letter size in points (612 x 792 = 8.5" x 11") @@ -195,11 +333,14 @@ export class PaginationEngine { private showPageNumbers: boolean; private pageGap: number; private fragmentParagraphs: boolean; + private layoutToken?: { documentVersion: number; rendererFingerprint: string }; private hfRegistry: HeaderFooterRegistry; private footnoteRegistry: FootnoteRegistry; private pendingFootnoteContinuation: FootnoteContinuation | null = null; /** Per-section `w:pgNumType` (start / format), read off the section wrappers. */ private pageNumbering: Map = new Map(); + private lastPages: PageInfo[] = []; + private expectedPageMapAnchorIds: Set = new Set(); /** * Creates a new pagination engine. @@ -234,6 +375,7 @@ export class PaginationEngine { this.showPageNumbers = options.showPageNumbers ?? true; this.pageGap = options.pageGap ?? 20; this.fragmentParagraphs = options.fragmentParagraphs ?? false; + this.layoutToken = options.layoutToken; this.hfRegistry = new Map(); this.footnoteRegistry = new Map(); } @@ -264,6 +406,25 @@ export class PaginationEngine { const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement]; + // Snapshot the addressable SOURCE inventory before flow moves nodes out of staging. PageMap + // completeness cannot be inferred from whatever survives into page boxes: that would let a + // dropped block silently disappear from both the DOM and the supposedly authoritative map. + // Running-story variants and cited notes are inventoried from their source registries. + this.expectedPageMapAnchorIds = new Set(); + const referencedFootnoteIds = new Set(); + for (const section of sectionsToProcess) { + this.collectExpectedSourceAnchors(section, this.expectedPageMapAnchorIds, true); + for (const reference of Array.from(section.querySelectorAll("[data-footnote-id]"))) { + if (reference.closest("#pagination-footnote-registry, #pagination-hf-registry")) continue; + const id = reference.dataset.footnoteId; + if (id) referencedFootnoteIds.add(id); + } + } + for (const id of referencedFootnoteIds) { + const source = this.footnoteRegistry.get(id); + if (source) this.collectExpectedSourceAnchors(source, this.expectedPageMapAnchorIds); + } + // Group adjacent sections into page runs. A `w:type="continuous"` section keeps // filling the page its predecessor started rather than opening a fresh one, so it // joins the previous run — provided the page box (size and margins) is unchanged, @@ -333,7 +494,244 @@ export class PaginationEngine { // Every page box exists now, so NUMPAGES has an answer and each PAGE marker knows its page. this.substitutePageNumberFields(pages.length); - return { totalPages: pages.length, pages }; + // Only running-story variants selected by a real page are expected to materialize. Read IDs + // from registry sources, not presentation clones, so a failed clone remains detectable. + for (const page of pages) { + const pageInSection = parseInt(page.element.dataset.pageInSection || "1", 10); + const header = this.selectHeader(page.sectionIndex, pageInSection, page.pageNumber); + const footer = this.selectFooter(page.sectionIndex, pageInSection, page.pageNumber); + if (header) this.collectExpectedSourceAnchors(header, this.expectedPageMapAnchorIds); + if (footer) this.collectExpectedSourceAnchors(footer, this.expectedPageMapAnchorIds); + } + + // Establish one active editor anchor and page-qualify every presentation fragment. + // Full canonical source identities remain on all clones, including table cells. + this.qualifyPageFragments(pages); + this.lastPages = pages; + + return { + totalPages: pages.length, + pages, + pageMap: this.layoutToken + ? this.materializePageMap( + this.layoutToken.documentVersion, + this.layoutToken.rendererFingerprint, + ) + : undefined, + }; + } + + /** + * Materialize the last completed browser layout as portable page-relative point geometry. + * The caller supplies both invalidation tokens; this engine never guesses a document version + * or renderer fingerprint. + */ + materializePageMap(documentVersion: number, rendererFingerprint: string): PageMap { + if (!Number.isSafeInteger(documentVersion) || documentVersion < 0) { + throw new Error("documentVersion must be a non-negative safe integer"); + } + if (!rendererFingerprint) throw new Error("rendererFingerprint must be non-empty"); + if (this.lastPages.length === 0) throw new Error("paginate() must complete before materializePageMap()"); + + const pages: PageMapPage[] = this.lastPages.map((page) => ({ + pageNumber: page.pageNumber, + pageInSection: parseInt(page.element.dataset.pageInSection || "1", 10), + width: page.dimensions.pageWidth, + height: page.dimensions.pageHeight, + sectionIndex: page.sectionIndex, + pageName: `docxodus-section-${page.sectionIndex}`, + })); + + const fragments: PageMapFragment[] = []; + const requiredAnchorIds = new Set(this.expectedPageMapAnchorIds); + const measuredAnchorIds = new Set(); + const emittedFragmentCounts = new Map(); + for (const page of this.lastPages) { + const pageRect = page.element.getBoundingClientRect(); + if (pageRect.width <= 0 || pageRect.height <= 0) { + throw new Error(`page ${page.pageNumber} has no measurable geometry`); + } + // Ratio-to-known-page-size removes CSS px, zoom, and transform from the contract. + const pointPerRenderedX = page.dimensions.pageWidth / pageRect.width; + const pointPerRenderedY = page.dimensions.pageHeight / pageRect.height; + const nodes = page.element.querySelectorAll("[data-source-anchor-id]"); + for (const element of Array.from(nodes)) { + const anchorId = element.dataset.sourceAnchorId; + if (!anchorId || !element.dataset.pageFragmentId + || !Number.isInteger(parseInt(element.dataset.fragmentIndex || "", 10))) { + throw new Error(`page ${page.pageNumber} contains an unqualified source anchor`); + } + + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const deliberatelyHidden = style.display === "none" || style.visibility === "hidden"; + if (deliberatelyHidden) continue; + requiredAnchorIds.add(anchorId); + const left = Math.max(pageRect.left, rect.left); + const top = Math.max(pageRect.top, rect.top); + const right = Math.min(pageRect.right, rect.right); + const bottom = Math.min(pageRect.bottom, rect.bottom); + if (rect.width <= 0 || rect.height <= 0 || right <= left || bottom <= top) { + // A continued note/story clone can contain children clipped off this page which become + // measurable on its next clone. Enforce completeness once every page has been inspected. + continue; + } + + measuredAnchorIds.add(anchorId); + // Clipped descendants in repeated note/story clones are deliberately omitted from the + // portable map. Re-number only the visible fragments so the emitted contract remains + // contiguous even when an earlier DOM clone carried no visible geometry on its page. + const fragmentIndex = emittedFragmentCounts.get(anchorId) ?? 0; + emittedFragmentCounts.set(anchorId, fragmentIndex + 1); + const fragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`; + element.dataset.fragmentIndex = String(fragmentIndex); + element.dataset.pageFragmentId = fragmentId; + fragments.push({ + fragmentId, + anchorId, + fragmentIndex, + pageNumber: page.pageNumber, + geometry: { + x: (left - pageRect.left) * pointPerRenderedX, + y: (top - pageRect.top) * pointPerRenderedY, + width: (right - left) * pointPerRenderedX, + height: (bottom - top) * pointPerRenderedY, + }, + story: this.storyForCanonicalAnchor(anchorId), + inTableCell: element.matches("td,th") || element.closest("td,th") !== null, + }); + } + } + + const missingAnchor = Array.from(requiredAnchorIds).find((id) => !measuredAnchorIds.has(id)); + if (missingAnchor) { + throw new Error(`source anchor ${missingAnchor} has no measurable fragment in the paginated layout`); + } + + return { + schemaVersion: 1, + mode: "paginated", + availability: "available", + documentVersion, + rendererFingerprint, + pages, + fragments, + }; + } + + private storyForCanonicalAnchor(anchorId: string): PageMapStory { + const first = anchorId.indexOf(":"); + const second = first < 0 ? -1 : anchorId.indexOf(":", first + 1); + const scope = first >= 0 && second > first ? anchorId.slice(first + 1, second) : "body"; + if (scope.startsWith("hdr")) return "header"; + if (scope.startsWith("ftr")) return "footer"; + if (scope === "fn") return "footnote"; + if (scope === "en") return "endnote"; + if (scope === "cmt") return "comment"; + return "body"; + } + + /** + * Add canonical IDs from an addressable source subtree to the pre-pagination inventory. + * Registry wrappers are excluded when scanning staging because selectable registry contents are + * inventoried separately. Producers may explicitly mark content that has no visual substrate + * with `data-page-map-exclude="true"`; native hidden semantics carry the same signal. + */ + private collectExpectedSourceAnchors( + source: HTMLElement, + destination: Set, + excludeRegistries = false, + ): void { + const candidates: HTMLElement[] = source.matches("[data-source-anchor-id]") ? [source] : []; + candidates.push(...Array.from(source.querySelectorAll("[data-source-anchor-id]"))); + for (const element of candidates) { + if (excludeRegistries && element.closest("#pagination-hf-registry, #pagination-footnote-registry")) { + continue; + } + if (this.isDeliberatelyUnrenderedSource(element, source)) continue; + const anchorId = element.dataset.sourceAnchorId; + if (anchorId) destination.add(anchorId); + } + } + + private isDeliberatelyUnrenderedSource(element: HTMLElement, sourceRoot: HTMLElement): boolean { + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + if ( + current.dataset.pageMapExclude === "true" + || current.hidden + || current.getAttribute("aria-hidden") === "true" + || current.style.display === "none" + || current.style.visibility === "hidden" + ) { + return true; + } + if (current === sourceRoot) break; + } + return false; + } + + /** + * Keep exactly one active bare-Unid editor anchor per source block. Presentation clones use + * canonical source identity plus page/fragment qualification instead. + */ + private qualifyPageFragments(pages: PageInfo[]): void { + const fragmentCounts = new Map(); + const activeCanonicalIds = new Set(); + const activeBareAnchorIds = new Set(); + + const makeInactive = (element: HTMLElement): void => { + element.removeAttribute("data-anchor"); + element.removeAttribute("data-committed-text"); + if (element.hasAttribute("contenteditable")) element.setAttribute("contenteditable", "false"); + }; + + for (const page of pages) { + const nodes = page.element.querySelectorAll("[data-source-anchor-id]"); + for (const element of Array.from(nodes)) { + const anchorId = element.dataset.sourceAnchorId; + if (!anchorId) continue; + const fragmentIndex = fragmentCounts.get(anchorId) ?? 0; + fragmentCounts.set(anchorId, fragmentIndex + 1); + element.dataset.pageNumber = String(page.pageNumber); + element.dataset.fragmentIndex = String(fragmentIndex); + element.dataset.pageFragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`; + + const story = this.storyForCanonicalAnchor(anchorId); + const mayOwnActiveEditorAnchor = + story === "body" || story === "comment" || story === "footnote" || story === "endnote"; + if (element.hasAttribute("data-anchor")) { + const bareAnchorId = element.dataset.anchor!; + if (mayOwnActiveEditorAnchor + && !activeCanonicalIds.has(anchorId) + && !activeBareAnchorIds.has(bareAnchorId)) { + activeCanonicalIds.add(anchorId); + activeBareAnchorIds.add(bareAnchorId); + } else { + makeInactive(element); + } + } + } + } + + // Body/comment page nodes are the editable copies. A repeated header/footer registry entry can + // also render the same source story once per section/variant, so retain at most one active + // staging node per canonical source and make every presentation duplicate inert. + const activeStagingCanonicalIds = new Set(); + for (const element of Array.from( + this.stagingElement.querySelectorAll("[data-source-anchor-id][data-anchor]"), + )) { + const anchorId = element.dataset.sourceAnchorId; + if (!anchorId) continue; + const bareAnchorId = element.dataset.anchor!; + if (activeCanonicalIds.has(anchorId) + || activeStagingCanonicalIds.has(anchorId) + || activeBareAnchorIds.has(bareAnchorId)) { + makeInactive(element); + } else { + activeStagingCanonicalIds.add(anchorId); + activeBareAnchorIds.add(bareAnchorId); + } + } } /** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */ diff --git a/npm/src/react.ts b/npm/src/react.ts index faee741a..debee790 100644 --- a/npm/src/react.ts +++ b/npm/src/react.ts @@ -30,6 +30,7 @@ import type { AddAnnotationWithTargetRequest, DocumentMetadata, SectionMetadata, + PageCitation, } from "./types.js"; import { AnnotationLabelMode, @@ -52,8 +53,11 @@ import { } from "./types.js"; import { PaginationEngine, + navigateToPageCitation, type PaginationOptions, type PaginationResult, + type PageMap, + type PageCitationNavigation, } from "./pagination.js"; export type { @@ -62,6 +66,9 @@ export type { Revision, PaginationOptions, PaginationResult, + PageMap, + PageCitationNavigation, + PageCitation, Annotation, AddAnnotationRequest, AddAnnotationResponse, @@ -601,6 +608,8 @@ export interface PaginatedDocumentProps { pageGap?: number; /** Whether simple paragraphs may fragment across page boundaries. Default: true. */ fragmentParagraphs?: boolean; + /** Exact tokens used to include an authoritative pageMap in onPaginationComplete. */ + layoutToken?: { documentVersion: number; rendererFingerprint: string }; /** Background color for the viewer. Default: "#525659" */ backgroundColor?: string; /** CSS class prefix used in the HTML. Default: "page-" */ @@ -609,6 +618,8 @@ export interface PaginatedDocumentProps { onPaginationComplete?: (result: PaginationResult) => void; /** Callback when a page becomes visible (for tracking current page) */ onPageVisible?: (pageNumber: number) => void; + /** Exact PageMap citation to navigate and highlight after pagination completes. */ + citation?: PageCitation; /** Additional CSS class for the container */ className?: string; /** Additional inline styles for the container */ @@ -669,6 +680,7 @@ export function usePagination( pageGap = 20, cssPrefix = "page-", fragmentParagraphs = true, + layoutToken, } = options; const paginate = useCallback(() => { @@ -706,6 +718,7 @@ export function usePagination( pageGap, cssPrefix, fragmentParagraphs, + layoutToken, }; const engine = new PaginationEngine(staging, pageContainer, engineOptions); const paginationResult = engine.paginate(); @@ -715,7 +728,17 @@ export function usePagination( } finally { setIsPaginating(false); } - }, [html, containerRef, scale, showPageNumbers, pageGap, cssPrefix, fragmentParagraphs]); + }, [ + html, + containerRef, + scale, + showPageNumbers, + pageGap, + cssPrefix, + fragmentParagraphs, + layoutToken?.documentVersion, + layoutToken?.rendererFingerprint, + ]); // Auto-paginate when HTML changes useEffect(() => { @@ -768,10 +791,12 @@ export function PaginatedDocument({ showPageNumbers = true, pageGap = 20, fragmentParagraphs = true, + layoutToken, backgroundColor = "#525659", cssPrefix = "page-", onPaginationComplete, onPageVisible, + citation, className, style, }: PaginatedDocumentProps): ReactElement { @@ -785,7 +810,16 @@ export function PaginatedDocument({ pageGap, cssPrefix, fragmentParagraphs, - }), [scale, showPageNumbers, pageGap, cssPrefix, fragmentParagraphs]); + layoutToken, + }), [ + scale, + showPageNumbers, + pageGap, + cssPrefix, + fragmentParagraphs, + layoutToken?.documentVersion, + layoutToken?.rendererFingerprint, + ]); const { result, isPaginating, error } = usePagination(html, containerRef, options); @@ -823,6 +857,13 @@ export function PaginatedDocument({ return () => observer.disconnect(); }, [result, cssPrefix, onPageVisible]); + useEffect(() => { + if (!result || !citation || !containerRef.current) return; + navigateToPageCitation(containerRef.current, citation, { + highlight: true, + }); + }, [result, citation, cssPrefix]); + // Use createElement to avoid TSX dependency const containerStyle: CSSProperties = { backgroundColor, diff --git a/npm/src/session.ts b/npm/src/session.ts index 66ba672a..9bbc6cc7 100644 --- a/npm/src/session.ts +++ b/npm/src/session.ts @@ -26,6 +26,10 @@ import type { NumberFormat, PageNumberField, PageNumberingOp, + PageCitation, + PageCitationRequest, + PageMapRegistrationResult, + PageMapStatus, ParagraphBorderEdge, ParagraphFormatOp, TableBorderSpec, @@ -45,6 +49,7 @@ import type { TemplatePlaceholder, TextMatch, } from "./types.js"; +import type { PageMap } from "./pagination.js"; import { ContextBoundary, DiffFormat, PlaceholderKinds, ProjectionDepth, TrackedChangeMode } from "./types.js"; /** @@ -77,6 +82,30 @@ export class DocxSession { return (JSON.parse(this.wasm.GetVersion(this.handle)) as { version: number }).version; } + /** Register a browser-materialized PageMap without changing the document version. */ + registerPageMap(pageMap: PageMap, expectedRendererFingerprint?: string): PageMapRegistrationResult { + return JSON.parse(this.wasm.RegisterPageMap( + this.handle, + JSON.stringify(pageMap), + expectedRendererFingerprint ?? "", + )) as PageMapRegistrationResult; + } + + getPageMapStatus(request?: PageCitationRequest): PageMapStatus { + return JSON.parse(this.wasm.GetPageMapStatus( + this.handle, + request ? JSON.stringify(request) : "", + )) as PageMapStatus; + } + + getPageCitation(anchorId: string, request: PageCitationRequest): PageCitation { + return JSON.parse(this.wasm.GetPageCitation( + this.handle, + anchorId, + JSON.stringify(request), + )) as PageCitation; + } + /** Evaluate optimistic guards without mutating or advancing the version. */ checkPreconditions(preconditions: MutationPreconditions): EditResult { return JSON.parse( @@ -115,9 +144,12 @@ export class DocxSession { projectAnchor( anchorId: string, depth: ProjectionDepth = ProjectionDepth.SubtreeAndFollowingSiblings, + citation?: PageCitationRequest, ): DocxSessionProjection { return JSON.parse( - this.wasm.ProjectAnchor(this.handle, anchorId, depth), + citation + ? this.wasm.ProjectAnchorWithCitations(this.handle, anchorId, depth, JSON.stringify(citation)) + : this.wasm.ProjectAnchor(this.handle, anchorId, depth), ) as DocxSessionProjection; } @@ -980,10 +1012,14 @@ export class DocxSession { scope: number = 1, contextChars: number = 80, boundary: number = ContextBoundary.Char, + citation?: PageCitationRequest, ): TemplatePlaceholder[] { - return JSON.parse( - this.wasm.FindPlaceholders(this.handle, kinds, scope, contextChars, boundary), - ) as TemplatePlaceholder[]; + const json = citation + ? this.wasm.FindPlaceholdersWithCitations( + this.handle, kinds, scope, contextChars, boundary, JSON.stringify(citation), + ) + : this.wasm.FindPlaceholders(this.handle, kinds, scope, contextChars, boundary); + return JSON.parse(json) as TemplatePlaceholder[]; } /** @@ -1042,8 +1078,11 @@ export class DocxSession { * * @see docs/architecture/docx_mutation_api.md#findbyannotation */ - findByAnnotation(annotationId: string): AnchorTargetRef[] { - return JSON.parse(this.wasm.FindByAnnotation(this.handle, annotationId)) as AnchorTargetRef[]; + findByAnnotation(annotationId: string, citation?: PageCitationRequest): AnchorTargetRef[] { + const json = citation + ? this.wasm.FindByAnnotationWithCitations(this.handle, annotationId, JSON.stringify(citation)) + : this.wasm.FindByAnnotation(this.handle, annotationId); + return JSON.parse(json) as AnchorTargetRef[]; } /** @@ -1053,8 +1092,11 @@ export class DocxSession { * annotations on different paragraphs become three entries). Annotations * whose bookmark resolves to no anchors are omitted from the result. */ - findByLabel(labelId: string): Record { - return JSON.parse(this.wasm.FindByLabel(this.handle, labelId)) as Record; + findByLabel(labelId: string, citation?: PageCitationRequest): Record { + const json = citation + ? this.wasm.FindByLabelWithCitations(this.handle, labelId, JSON.stringify(citation)) + : this.wasm.FindByLabel(this.handle, labelId); + return JSON.parse(json) as Record; } /** @@ -1063,8 +1105,11 @@ export class DocxSession { * order. Empty when the bookmark name is unknown. Use this for raw bookmark * names that didn't come from the annotation system. */ - findByBookmark(bookmarkName: string): AnchorTargetRef[] { - return JSON.parse(this.wasm.FindByBookmark(this.handle, bookmarkName)) as AnchorTargetRef[]; + findByBookmark(bookmarkName: string, citation?: PageCitationRequest): AnchorTargetRef[] { + const json = citation + ? this.wasm.FindByBookmarkWithCitations(this.handle, bookmarkName, JSON.stringify(citation)) + : this.wasm.FindByBookmark(this.handle, bookmarkName); + return JSON.parse(json) as AnchorTargetRef[]; } // ─── Text/kind-based anchor discovery (#171) ───────────────────────── @@ -1120,10 +1165,13 @@ export class DocxSession { * index directly — no text scan. Pass `scope` (e.g. `"body"`) to restrict to * a single part; omit it to span all scopes. */ - findByKind(kind: string, scope?: string): AnchorTargetRef[] { - return JSON.parse( - this.wasm.FindByKind(this.handle, kind, scope ?? ""), - ) as AnchorTargetRef[]; + findByKind(kind: string, scope?: string, citation?: PageCitationRequest): AnchorTargetRef[] { + const json = citation + ? this.wasm.FindByKindWithCitations( + this.handle, kind, scope ?? "", JSON.stringify(citation), + ) + : this.wasm.FindByKind(this.handle, kind, scope ?? ""); + return JSON.parse(json) as AnchorTargetRef[]; } /** @@ -1285,5 +1333,5 @@ export function openDocxSession( return new DocxSession(handle, bridge); } -export type { AnchorInfo, AnchorRef, AnchorTargetRef, BlockSlice, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, DocxSessionProjection, DocxSessionSettings, EditError, EditErrorCode, EditResult, FindOptions, FormatOp, GrepOptions, MarkdownPatch, MutationPreconditions, PlaceholderKind, PreconditionFailure, PreconditionTarget, ReplaceOptions, RunFormatting, RunFragment, TemplatePlaceholder, TextMatch, TextRangePrecondition } from "./types.js"; +export type { AnchorInfo, AnchorRef, AnchorTargetRef, BlockSlice, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, DocxSessionProjection, DocxSessionSettings, EditError, EditErrorCode, EditResult, FindOptions, FormatOp, GrepOptions, MarkdownPatch, MutationPreconditions, PageCitation, PageCitationRequest, PageMapRegistrationResult, PageMapStatus, PlaceholderKind, PreconditionFailure, PreconditionTarget, ReplaceOptions, RunFormatting, RunFragment, TemplatePlaceholder, TextMatch, TextRangePrecondition } from "./types.js"; export { ContextBoundary, PlaceholderKinds } from "./types.js"; diff --git a/npm/src/types.ts b/npm/src/types.ts index b772fc06..169dedb2 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1050,8 +1050,17 @@ export interface DocxodusWasmExports { CreateBlankDocx: () => Uint8Array; Project: (handle: number) => string; GetVersion: (handle: number) => string; + RegisterPageMap: (handle: number, pageMapJson: string, expectedRendererFingerprint: string) => string; + GetPageMapStatus: (handle: number, requestJson: string) => string; + GetPageCitation: (handle: number, anchorId: string, requestJson: string) => string; CheckPreconditions: (handle: number, preconditionsJson: string) => string; ProjectAnchor: (handle: number, anchorId: string, depth: number) => string; + ProjectAnchorWithCitations: ( + handle: number, + anchorId: string, + depth: number, + requestJson: string, + ) => string; /** Ordered top-level render units per scope container (JSON {@link RenderPlan}) — * what the editor's incremental reconciler diffs its DOM against. Optional: * absent on older WASM bundles. */ @@ -1210,17 +1219,29 @@ export interface DocxodusWasmExports { newInner: string, ) => string; FindPlaceholders: (handle: number, kinds: number, scope: number, contextChars: number, boundary: number) => string; + FindPlaceholdersWithCitations: ( + handle: number, + kinds: number, + scope: number, + contextChars: number, + boundary: number, + requestJson: string, + ) => string; GetEditSummary: (handle: number) => string; RemainingPlaceholders: (handle: number, kinds: number) => string; GetDiff: (handle: number, format: number) => string; FindByAnnotation: (handle: number, annotationId: string) => string; + FindByAnnotationWithCitations: (handle: number, annotationId: string, requestJson: string) => string; FindByLabel: (handle: number, labelId: string) => string; + FindByLabelWithCitations: (handle: number, labelId: string, requestJson: string) => string; FindByBookmark: (handle: number, bookmarkName: string) => string; + FindByBookmarkWithCitations: (handle: number, bookmarkName: string, requestJson: string) => string; Exists: (handle: number, anchorId: string) => boolean; FindByText: (handle: number, needle: string, optionsJson: string) => string; FindAllByText: (handle: number, needle: string, optionsJson: string) => string; FindByRegex: (handle: number, pattern: string, regexOptions: number, optionsJson: string) => string; FindByKind: (handle: number, kind: string, scope: string) => string; + FindByKindWithCitations: (handle: number, kind: string, scope: string, requestJson: string) => string; GetAnchorInfo: (handle: number, anchorId: string) => string; GetAnchorInfos: (handle: number, anchorIdsJson: string) => string; GetBlockMetadata: (handle: number, anchorId: string) => string; @@ -1741,6 +1762,53 @@ export interface DocxSessionProjection { scope: string; textPreview: string; }>; + /** Present only when projectAnchor requested citations. */ + pageCitations?: Record; +} + +export interface PageCitationRequest { + documentVersion: number; + rendererFingerprint: string; +} + +export type PageCitationUnavailableReason = + | "no_page_map" + | "continuous_mode" + | "stale_document_version" + | "renderer_fingerprint_mismatch" + | "anchor_not_mapped"; + +export interface PageCitationFragment { + fragmentId: string; + anchorId: string; + fragmentIndex: number; + pageNumber: number; + geometry: { x: number; y: number; width: number; height: number }; + story: "body" | "header" | "footer" | "footnote" | "endnote" | "comment"; + inTableCell: boolean; +} + +export interface PageCitation { + anchorId: string; + availability: "available" | "unavailable"; + unavailableReason?: PageCitationUnavailableReason; + documentVersion: number; + rendererFingerprint: string; + fragments: PageCitationFragment[]; +} + +export interface PageMapRegistrationResult { + success: boolean; + error?: "unsupported_schema_version" | "stale_document_version" | "renderer_fingerprint_mismatch" | "invalid_map"; + message?: string; +} + +export interface PageMapStatus { + availability: "available" | "unavailable"; + unavailableReason?: PageCitationUnavailableReason; + documentVersion: number; + rendererFingerprint?: string; + mode?: "paginated" | "continuous"; } /** @@ -1784,6 +1852,8 @@ export interface TextMatch { contextAfter: string; /** Regex capture groups; index 0 is always the whole match. */ groups: string[]; + /** Present only when grep requested a citation for this exact render. */ + citation?: PageCitation; } /** @@ -1816,6 +1886,8 @@ export interface CrossBlockMatch { contextAfter: string; /** Regex capture groups; index 0 is always the whole match. */ groups: string[]; + /** One per enclosingAnchors entry when requested. */ + citations?: PageCitation[]; } /** @@ -2013,6 +2085,8 @@ export interface GrepOptions { * `contextChars`. */ boundary?: number; + /** Attach citations only if this exact registered layout is still valid. */ + citation?: PageCitationRequest; } /** @@ -2039,6 +2113,8 @@ export interface FindOptions { * for whole-category filtering; this is for the rare single-part case. */ scopeFilter?: string; + /** Attach citations only if this exact registered layout is still valid. */ + citation?: PageCitationRequest; } /** @@ -2054,6 +2130,8 @@ export interface AnchorTargetRef extends AnchorRef { /** Resolved auto-numbering prefix (e.g. "1.", "First") when the element carries * numbering. Absent otherwise. See {@link MarkdownAnchorTarget.autoNumberPrefix}. */ autoNumberPrefix?: string; + /** Present only when the discovery call requested an exact page citation. */ + citation?: PageCitation; } /** @@ -2903,6 +2981,8 @@ export interface DocumentMetadata { hasComments: boolean; /** Estimated total page count (heuristic based on content volume and page sizes) */ estimatedPageCount: number; + /** Explicit provenance: always "heuristic"; use PageMap for authoritative pages. */ + estimatedPageCountSource: "heuristic"; } // ============================================================================ diff --git a/npm/tests/page-map.spec.ts b/npm/tests/page-map.spec.ts new file mode 100644 index 00000000..6b53e710 --- /dev/null +++ b/npm/tests/page-map.spec.ts @@ -0,0 +1,292 @@ +import { expect, Page, test } from '@playwright/test'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function addBundle(page: Page): Promise { + await page.addScriptTag({ path: path.join(__dirname, '../dist/pagination.bundle.js') }); +} + +function shell(content: string): string { + return `
${content}
`; +} + +const words = Array.from({ length: 90 }, (_, i) => `word${i}`).join(' '); + +test.describe('PageMap materialization and citation navigation', () => { + test('the public helper returns scale-independent paragraph fragments and navigates them', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + + const run = async (scale: number) => page.evaluate(({ html, scale }) => { + const api = (window as any).DocxodusPagination; + const viewer = document.getElementById('viewer') as HTMLElement; + const result = api.paginateHtml(html, viewer, { + scale, + showPageNumbers: false, + layoutToken: { documentVersion: 7, rendererFingerprint: 'chromium-layout-v1' }, + }); + const map = result.pageMap; + const citation = { + availability: 'available', + anchorId: 'p:body:shared-unid', + fragments: map.fragments.filter((f: any) => f.anchorId === 'p:body:shared-unid'), + }; + const navigation = api.navigateToPageCitation(viewer, citation, { behavior: 'auto' }); + return { + totalPages: result.totalPages, + map: { + schemaVersion: map.schemaVersion, + documentVersion: map.documentVersion, + rendererFingerprint: map.rendererFingerprint, + pages: map.pages, + fragments: citation.fragments, + }, + active: viewer.querySelectorAll( + '[data-source-anchor-id="p:body:shared-unid"][data-anchor]', + ).length, + navigated: navigation.navigated, + highlighted: navigation.target?.style.outline.includes('solid') ?? false, + }; + }, { + scale, + html: shell(` +
+

${words}

+
`), + }); + + const normal = await run(1); + const scaled = await run(0.55); + expect(normal.totalPages).toBeGreaterThan(1); + expect(normal.map.schemaVersion).toBe(1); + expect(normal.map.documentVersion).toBe(7); + expect(normal.map.rendererFingerprint).toBe('chromium-layout-v1'); + expect(normal.map.fragments.length).toBeGreaterThan(1); + expect(normal.map.fragments.map((f: any) => f.fragmentIndex)) + .toEqual(normal.map.fragments.map((_: any, i: number) => i)); + expect(normal.active).toBe(1); + expect(normal.navigated).toBe(true); + expect(normal.highlighted).toBe(true); + + expect(scaled.map.pages.map((p: any) => [p.width, p.height])) + .toEqual(normal.map.pages.map((p: any) => [p.width, p.height])); + for (let i = 0; i < normal.map.fragments.length; i++) { + const a = normal.map.fragments[i].geometry; + const b = scaled.map.fragments[i].geometry; + expect(b.x).toBeCloseTo(a.x, 1); + expect(b.y).toBeCloseTo(a.y, 1); + expect(b.width).toBeCloseTo(a.width, 1); + expect(b.height).toBeCloseTo(a.height, 1); + } + }); + + test('maps split tables, repeated stories, continued notes, comments, and collision-safe identities', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + + const result = await page.evaluate((html) => { + const api = (window as any).DocxodusPagination; + const viewer = document.getElementById('viewer') as HTMLElement; + const pagination = api.paginateHtml(html, viewer, { + showPageNumbers: false, + layoutToken: { documentVersion: 0, rendererFingerprint: 'stories-v1' }, + }); + const map = pagination.pageMap; + const activeByCanonical = Array.from(viewer.querySelectorAll('[data-source-anchor-id]')) + .filter((node) => node.hasAttribute('data-anchor')) + .reduce>((counts, node) => { + const id = node.dataset.sourceAnchorId!; + counts[id] = (counts[id] ?? 0) + 1; + return counts; + }, {}); + const storyPages = (story: string) => Array.from(new Set( + map.fragments.filter((f: any) => f.story === story).map((f: any) => f.pageNumber), + )); + return { + totalPages: pagination.totalPages, + fragments: map.fragments, + storyPages: { + header: storyPages('header'), + footer: storyPages('footer'), + footnote: storyPages('footnote'), + endnote: storyPages('endnote'), + comment: storyPages('comment'), + }, + activeByCanonical, + activeBareIds: Array.from(viewer.querySelectorAll('[data-anchor]')) + .map((node) => node.dataset.anchor), + sharedBareSources: Array.from(viewer.querySelectorAll('[data-anchor="same"]')) + .map((node) => node.dataset.sourceAnchorId), + }; + }, shell(` + +
+

+ body +

+
+ ${Array.from({ length: 5 }, (_, i) => ` + + + `).join('')} +
+

cell ${i}

+
+

visible comment body

+

continued footnote ${words}

+

continued endnote ${words}

+
`)); + + expect(result.totalPages).toBeGreaterThan(2); + expect(result.storyPages.header.length).toBe(result.totalPages); + expect(result.storyPages.footer.length).toBe(result.totalPages); + expect(result.storyPages.footnote.length).toBeGreaterThan(1); + expect(result.storyPages.endnote.length).toBeGreaterThan(1); + expect(result.storyPages.comment.length).toBe(1); + + const tableFragments = result.fragments.filter((f: any) => f.anchorId === 'tbl:body:table'); + expect(tableFragments.length).toBeGreaterThan(1); + const cell = result.fragments.find((f: any) => f.anchorId === 'tc:body:c0'); + expect(cell.inTableCell).toBe(true); + expect(result.fragments.some((f: any) => f.anchorId.startsWith('tr:body:'))).toBe(true); + const indicesByAnchor = new Map(); + for (const fragment of result.fragments) { + const indices = indicesByAnchor.get(fragment.anchorId) ?? []; + indices.push(fragment.fragmentIndex); + indicesByAnchor.set(fragment.anchorId, indices); + } + for (const indices of indicesByAnchor.values()) { + expect(indices).toEqual(indices.map((_: number, index: number) => index)); + } + + for (const count of Object.values(result.activeByCanonical)) expect(count).toBe(1); + expect(result.activeByCanonical['p:body:same']).toBe(1); + expect(result.activeByCanonical['p:hdr1:same']).toBeUndefined(); + expect(result.activeByCanonical['p:fn:note']).toBe(1); + expect(new Set(result.activeBareIds).size).toBe(result.activeBareIds.length); + expect(result.sharedBareSources).toEqual(['p:body:same']); + }); + + test('captures columns and mixed page names/sizes', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + + const result = await page.evaluate((html) => { + const api = (window as any).DocxodusPagination; + const pagination = api.paginateHtml(html, 'viewer', { + showPageNumbers: false, + layoutToken: { documentVersion: 3, rendererFingerprint: 'mixed-v1' }, + }); + return { + pages: pagination.pageMap.pages, + columnFragments: pagination.pageMap.fragments + .filter((f: any) => f.anchorId.startsWith('p:body:column-')), + }; + }, shell(` +
+ ${Array.from({ length: 8 }, (_, i) => + `

column ${i}

`).join('')} +
+
+

wide

+
`)); + + expect(result.columnFragments.length).toBe(8); + expect(new Set(result.pages.map((p: any) => p.pageName))) + .toEqual(new Set(['docxodus-section-0', 'docxodus-section-1'])); + expect(result.pages.some((p: any) => p.width === 122 && p.height === 80)).toBe(true); + expect(result.pages.some((p: any) => p.width === 200 && p.height === 140)).toBe(true); + expect(result.pages.every((p: any) => p.pageInSection >= 1)).toBe(true); + }); + + test('refuses to publish an available map with an unmeasurable addressable block', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + const message = await page.evaluate((html) => { + try { + (window as any).DocxodusPagination.paginateHtml(html, 'viewer', { + showPageNumbers: false, + layoutToken: { documentVersion: 0, rendererFingerprint: 'strict-v1' }, + }); + return ''; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, shell(` +
+

+
`)); + expect(message).toContain('has no measurable fragment'); + }); + + test('refuses a map when an addressable staging block never reaches a page', async ({ page }) => { + await page.setContent(shell(` +
+

kept

+

dropped

+
`)); + await addBundle(page); + + const message = await page.evaluate(() => { + const api = (window as any).DocxodusPagination; + const engine = new api.PaginationEngine('pagination-staging', 'pagination-container', { + showPageNumbers: false, + layoutToken: { documentVersion: 0, rendererFingerprint: 'dropped-source-v1' }, + }); + const originalMeasureBlocks = engine.measureBlocks.bind(engine); + engine.measureBlocks = (section: HTMLElement, dimensions: unknown) => + originalMeasureBlocks(section, dimensions) + .filter((block: any) => block.element.dataset.dropFromFlow !== 'true'); + try { + engine.paginate(); + return ''; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }); + + expect(message).toContain('source anchor p:body:dropped has no measurable fragment'); + }); +}); diff --git a/python/README.md b/python/README.md index e897c90e..3b94683f 100644 --- a/python/README.md +++ b/python/README.md @@ -116,7 +116,7 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a | Tier | Methods | |---|---| -| **Lifecycle** | `save`, `close`, `undo`, `redo`, `get_version`, `to_html` | +| **Lifecycle** | `save`, `close`, `undo`, `redo`, `get_version`, `to_html`, `register_page_map`, `get_page_map_status`, `get_page_citation` | | **Projection** | `project`, `project_anchor` | | **Discovery** | `grep`, `grep_cross_block`, `find_placeholders`, `find_by_text`, `find_all_by_text`, `find_by_regex`, `find_by_kind`, `find_by_annotation`, `find_by_label`, `find_by_bookmark`, `list_annotations`, `exists`, `get_anchor_info`, `get_anchor_infos`, `get_edit_summary`, `remaining_placeholders`, `get_diff` | | **Inspection** | `get_block_metadata`, `get_block_metadatas`, `get_list_membership`, `get_section_info` | @@ -133,6 +133,13 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a Every mutation method returns an `EditResult` envelope — transport-level failures raise `DocxodusTransportError`, but a business outcome (`anchor_not_found`, `malformed_markdown`, etc.) returns `EditResult(success=False, error=EditError(...))`. **Never** an exception across the API boundary. +`PageMap` accepts physical pagination materialized by an external renderer. Registration requires +the session's exact document version and validates the renderer fingerprint, page/section order, +canonical anchors, geometry, story, table ownership, and fragment order. Pass the same +`PageCitationRequest` to search/scoped reads to attach citations. Continuous/no-map and stale +layouts return typed unavailable results; the client never guesses page numbers. See the +[portable PageMap contract](../docs/architecture/page_map.md). + For optimistic concurrency, build a `MutationPreconditions` object and use `session.check_preconditions(...)` for a read-only probe or `with session.preconditioned(guards): ...` to attach it to each mutation request in diff --git a/python/src/docx_scalpel/__init__.py b/python/src/docx_scalpel/__init__.py index 1000e62b..b3f46b15 100644 --- a/python/src/docx_scalpel/__init__.py +++ b/python/src/docx_scalpel/__init__.py @@ -112,6 +112,14 @@ NumberFormat, ParagraphBorderEdge, ParagraphFormatOp, + PageCitation, + PageCitationRequest, + PageMap, + PageMapFragment, + PageMapPage, + PageMapRect, + PageMapRegistrationResult, + PageMapStatus, ReplaceOptions, RetainedTableAnchor, PreconditionFailure, @@ -191,6 +199,14 @@ "NumberFormat", "ParagraphBorderEdge", "ParagraphFormatOp", + "PageCitation", + "PageCitationRequest", + "PageMap", + "PageMapFragment", + "PageMapPage", + "PageMapRect", + "PageMapRegistrationResult", + "PageMapStatus", "ReplaceOptions", "RetainedTableAnchor", "PreconditionFailure", diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py index c2ecd735..c5a791a5 100644 --- a/python/src/docx_scalpel/session.py +++ b/python/src/docx_scalpel/session.py @@ -71,6 +71,11 @@ MutationPreconditions, NumberFormat, ParagraphFormatOp, + PageCitation, + PageCitationRequest, + PageMap, + PageMapRegistrationResult, + PageMapStatus, ReplaceOptions, RevisionListEntry, SectionInfo, @@ -470,6 +475,37 @@ def get_version(self) -> int: raise TypeError(f"get_version: expected {{version: int}}, got {result!r}") return int(result["version"]) + def register_page_map( + self, page_map: PageMap, expected_renderer_fingerprint: str | None = None + ) -> PageMapRegistrationResult: + """Register browser-produced pagination without mutating the document.""" + result = self._call( + "register_page_map", + { + "pageMap": page_map.to_wire(), + "expectedRendererFingerprint": expected_renderer_fingerprint, + }, + ) + return PageMapRegistrationResult._from_wire(result) + + def get_page_map_status( + self, citation: PageCitationRequest | None = None + ) -> PageMapStatus: + args: dict[str, Any] = {} + if citation is not None: + args["citation"] = citation.to_wire() + return PageMapStatus._from_wire(self._call("get_page_map_status", args)) + + def get_page_citation( + self, anchor_id: str, citation: PageCitationRequest + ) -> PageCitation: + return PageCitation._from_wire( + self._call( + "get_page_citation", + {"anchorId": anchor_id, "citation": citation.to_wire()}, + ) + ) + def check_preconditions(self, preconditions: MutationPreconditions) -> EditResult: """Evaluate guards without mutating the document or advancing its version.""" return EditResult._from_wire( @@ -503,14 +539,13 @@ def project_anchor( self, anchor_id: str, depth: ProjectionDepth = ProjectionDepth.SUBTREE_AND_FOLLOWING_SIBLINGS, + citation: PageCitationRequest | None = None, ) -> MarkdownProjection: """Scoped re-projection rooted at ``anchor_id``.""" - return MarkdownProjection._from_wire( - self._call( - "project_anchor", - {"anchorId": anchor_id, "depth": int(depth)}, - ) - ) + args: dict[str, Any] = {"anchorId": anchor_id, "depth": int(depth)} + if citation is not None: + args["citation"] = citation.to_wire() + return MarkdownProjection._from_wire(self._call("project_anchor", args)) # -- discovery: grep + find ------------------------------------------- @@ -522,17 +557,21 @@ def grep( context_chars: int = 80, whitespace: WhitespaceMode = WhitespaceMode.PRESERVE, boundary: ContextBoundary = ContextBoundary.CHAR, + citation: PageCitationRequest | None = None, ) -> tuple[TextMatch, ...]: + args: dict[str, Any] = { + "pattern": pattern, + "regexOptions": int(regex_options), + "scope": int(scope), + "contextChars": context_chars, + "whitespace": int(whitespace), + "boundary": int(boundary), + } + if citation is not None: + args["citation"] = citation.to_wire() result = self._call( "grep", - { - "pattern": pattern, - "regexOptions": int(regex_options), - "scope": int(scope), - "contextChars": context_chars, - "whitespace": int(whitespace), - "boundary": int(boundary), - }, + args, ) return tuple(TextMatch._from_wire(m) for m in result) @@ -544,17 +583,21 @@ def grep_cross_block( context_chars: int = 80, whitespace: WhitespaceMode = WhitespaceMode.PRESERVE, boundary: ContextBoundary = ContextBoundary.CHAR, + citation: PageCitationRequest | None = None, ) -> tuple[CrossBlockMatch, ...]: + args: dict[str, Any] = { + "pattern": pattern, + "regexOptions": int(regex_options), + "scope": int(scope), + "contextChars": context_chars, + "whitespace": int(whitespace), + "boundary": int(boundary), + } + if citation is not None: + args["citation"] = citation.to_wire() result = self._call( "grep_cross_block", - { - "pattern": pattern, - "regexOptions": int(regex_options), - "scope": int(scope), - "contextChars": context_chars, - "whitespace": int(whitespace), - "boundary": int(boundary), - }, + args, ) return tuple(CrossBlockMatch._from_wire(m) for m in result) @@ -564,15 +607,19 @@ def find_placeholders( scope: ProjectionScopes = ProjectionScopes.BODY, context_chars: int = 80, boundary: ContextBoundary = ContextBoundary.CHAR, + citation: PageCitationRequest | None = None, ) -> tuple[TemplatePlaceholder, ...]: + args: dict[str, Any] = { + "kinds": int(kinds), + "scope": int(scope), + "contextChars": context_chars, + "boundary": int(boundary), + } + if citation is not None: + args["citation"] = citation.to_wire() result = self._call( "find_placeholders", - { - "kinds": int(kinds), - "scope": int(scope), - "contextChars": context_chars, - "boundary": int(boundary), - }, + args, ) return tuple(TemplatePlaceholder._from_wire(p) for p in result) @@ -717,26 +764,48 @@ def find_by_regex( result = self._call("find_by_regex", args) return tuple(AnchorTarget._from_wire(a) for a in result) - def find_by_kind(self, kind: str, scope: str | None = None) -> tuple[AnchorTarget, ...]: + def find_by_kind( + self, + kind: str, + scope: str | None = None, + citation: PageCitationRequest | None = None, + ) -> tuple[AnchorTarget, ...]: args: dict[str, Any] = {"kind": kind} if scope is not None: args["scope"] = scope + if citation is not None: + args["citation"] = citation.to_wire() result = self._call("find_by_kind", args) return tuple(AnchorTarget._from_wire(a) for a in result) - def find_by_annotation(self, annotation_id: str) -> tuple[AnchorTarget, ...]: - result = self._call("find_by_annotation", {"annotationId": annotation_id}) + def find_by_annotation( + self, annotation_id: str, citation: PageCitationRequest | None = None + ) -> tuple[AnchorTarget, ...]: + args: dict[str, Any] = {"annotationId": annotation_id} + if citation is not None: + args["citation"] = citation.to_wire() + result = self._call("find_by_annotation", args) return tuple(AnchorTarget._from_wire(a) for a in result) - def find_by_label(self, label_id: str) -> Mapping[str, tuple[AnchorTarget, ...]]: - result = self._call("find_by_label", {"labelId": label_id}) + def find_by_label( + self, label_id: str, citation: PageCitationRequest | None = None + ) -> Mapping[str, tuple[AnchorTarget, ...]]: + args: dict[str, Any] = {"labelId": label_id} + if citation is not None: + args["citation"] = citation.to_wire() + result = self._call("find_by_label", args) return { ann_id: tuple(AnchorTarget._from_wire(a) for a in anchors) for ann_id, anchors in result.items() } - def find_by_bookmark(self, bookmark_name: str) -> tuple[AnchorTarget, ...]: - result = self._call("find_by_bookmark", {"bookmarkName": bookmark_name}) + def find_by_bookmark( + self, bookmark_name: str, citation: PageCitationRequest | None = None + ) -> tuple[AnchorTarget, ...]: + args: dict[str, Any] = {"bookmarkName": bookmark_name} + if citation is not None: + args["citation"] = citation.to_wire() + result = self._call("find_by_bookmark", args) return tuple(AnchorTarget._from_wire(a) for a in result) def list_annotations(self) -> tuple[DocumentAnnotation, ...]: diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py index d14d53ad..5aa4af92 100644 --- a/python/src/docx_scalpel/types.py +++ b/python/src/docx_scalpel/types.py @@ -65,6 +65,14 @@ "HtmlOptions", "ListMembership", "NumberFormat", + "PageCitation", + "PageCitationRequest", + "PageMap", + "PageMapFragment", + "PageMapPage", + "PageMapRect", + "PageMapRegistrationResult", + "PageMapStatus", "RunFormatting", "RunFragment", "SectionInfo", @@ -298,6 +306,7 @@ class AnchorTarget: unid: str part_uri: str text_preview: str + citation: PageCitation | None = None @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "AnchorTarget": @@ -308,6 +317,7 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "AnchorTarget": unid=d["unid"], part_uri=d.get("partUri", ""), text_preview=d.get("textPreview", ""), + citation=PageCitation._from_wire(d["citation"]) if d.get("citation") else None, ) @@ -742,6 +752,164 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "RunFragment": # --------------------------------------------------------------------------- +@dataclass(frozen=True, slots=True) +class PageMapRect: + x: float + y: float + width: float + height: float + + def to_wire(self) -> dict[str, float]: + return {"x": self.x, "y": self.y, "width": self.width, "height": self.height} + + +@dataclass(frozen=True, slots=True) +class PageMapPage: + page_number: int + page_in_section: int + width: float + height: float + page_name: str + section_index: int | None = None + + def to_wire(self) -> dict[str, Any]: + out: dict[str, Any] = { + "pageNumber": self.page_number, + "pageInSection": self.page_in_section, + "width": self.width, + "height": self.height, + } + out["pageName"] = self.page_name + if self.section_index is not None: + out["sectionIndex"] = self.section_index + return out + + +@dataclass(frozen=True, slots=True) +class PageMapFragment: + fragment_id: str + anchor_id: str + fragment_index: int + page_number: int + geometry: PageMapRect + story: str + in_table_cell: bool = False + + def to_wire(self) -> dict[str, Any]: + return { + "fragmentId": self.fragment_id, + "anchorId": self.anchor_id, + "fragmentIndex": self.fragment_index, + "pageNumber": self.page_number, + "geometry": self.geometry.to_wire(), + "story": self.story, + "inTableCell": self.in_table_cell, + } + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "PageMapFragment": + g = d["geometry"] + return cls( + fragment_id=d["fragmentId"], + anchor_id=d["anchorId"], + fragment_index=int(d["fragmentIndex"]), + page_number=int(d["pageNumber"]), + geometry=PageMapRect( + float(g["x"]), + float(g["y"]), + float(g["width"]), + float(g["height"]), + ), + story=d["story"], + in_table_cell=bool(d.get("inTableCell", False)), + ) + + +@dataclass(frozen=True, slots=True) +class PageMap: + document_version: int + renderer_fingerprint: str + pages: tuple[PageMapPage, ...] = () + fragments: tuple[PageMapFragment, ...] = () + mode: str = "paginated" + availability: str = "available" + schema_version: int = 1 + + def to_wire(self) -> dict[str, Any]: + return { + "schemaVersion": self.schema_version, + "mode": self.mode, + "availability": self.availability, + "documentVersion": self.document_version, + "rendererFingerprint": self.renderer_fingerprint, + "pages": [p.to_wire() for p in self.pages], + "fragments": [f.to_wire() for f in self.fragments], + } + + +@dataclass(frozen=True, slots=True) +class PageCitationRequest: + document_version: int + renderer_fingerprint: str + + def to_wire(self) -> dict[str, Any]: + return { + "documentVersion": self.document_version, + "rendererFingerprint": self.renderer_fingerprint, + } + + +@dataclass(frozen=True, slots=True) +class PageCitation: + anchor_id: str + availability: str + document_version: int + renderer_fingerprint: str + fragments: tuple[PageMapFragment, ...] = () + unavailable_reason: str | None = None + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "PageCitation": + return cls( + anchor_id=d["anchorId"], + availability=d["availability"], + document_version=int(d["documentVersion"]), + renderer_fingerprint=d.get("rendererFingerprint", ""), + fragments=tuple(PageMapFragment._from_wire(f) for f in d.get("fragments", ())), + unavailable_reason=d.get("unavailableReason"), + ) + + +@dataclass(frozen=True, slots=True) +class PageMapRegistrationResult: + success: bool + error: str | None = None + message: str | None = None + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "PageMapRegistrationResult": + return cls(bool(d.get("success")), d.get("error"), d.get("message")) + + +@dataclass(frozen=True, slots=True) +class PageMapStatus: + availability: str + document_version: int + unavailable_reason: str | None = None + renderer_fingerprint: str | None = None + mode: str | None = None + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "PageMapStatus": + return cls( + d["availability"], + int(d["documentVersion"]), + d.get("unavailableReason"), + d.get("rendererFingerprint"), + d.get("mode"), + ) + + @dataclass(frozen=True, slots=True) class TextMatch: """A single grep / find-by-text match.""" @@ -753,6 +921,7 @@ class TextMatch: context_before: str = "" context_after: str = "" groups: tuple[str, ...] = () + citation: PageCitation | None = None @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "TextMatch": @@ -764,6 +933,7 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "TextMatch": context_before=d.get("contextBefore", ""), context_after=d.get("contextAfter", ""), groups=tuple(d.get("groups", ())), + citation=PageCitation._from_wire(d["citation"]) if d.get("citation") else None, ) @@ -794,6 +964,7 @@ class CrossBlockMatch: context_before: str = "" context_after: str = "" groups: tuple[str, ...] = () + citations: tuple[PageCitation, ...] = () @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "CrossBlockMatch": @@ -804,6 +975,7 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "CrossBlockMatch": context_before=d.get("contextBefore", ""), context_after=d.get("contextAfter", ""), groups=tuple(d.get("groups", ())), + citations=tuple(PageCitation._from_wire(c) for c in d.get("citations", ())), ) @@ -1017,6 +1189,7 @@ class MarkdownProjection: markdown: str anchor_index: Mapping[str, AnchorTarget] + page_citations: Mapping[str, PageCitation] = field(default_factory=dict) @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "MarkdownProjection": @@ -1033,7 +1206,11 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "MarkdownProjection": part_uri=entry.get("partUri", ""), text_preview=entry.get("textPreview", ""), ) - return cls(markdown=d.get("markdown", ""), anchor_index=decoded) + citations = { + anchor_id: PageCitation._from_wire(citation) + for anchor_id, citation in (d.get("pageCitations", {}) or {}).items() + } + return cls(markdown=d.get("markdown", ""), anchor_index=decoded, page_citations=citations) # --------------------------------------------------------------------------- @@ -1180,6 +1357,7 @@ class FindOptions: kind_filter: str | None = None scopes: ProjectionScopes | None = None scope_filter: str | None = None + citation: PageCitationRequest | None = None def to_wire(self) -> dict[str, Any]: out: dict[str, Any] = {} @@ -1188,6 +1366,8 @@ def to_wire(self) -> dict[str, Any]: if self.kind_filter is not None: out["kindFilter"] = self.kind_filter if self.scopes is not None: out["scopes"] = int(self.scopes) if self.scope_filter is not None: out["scopeFilter"] = self.scope_filter + if self.citation is not None: + out["citation"] = self.citation.to_wire() return out diff --git a/python/tests/test_page_map.py b/python/tests/test_page_map.py new file mode 100644 index 00000000..fb20f088 --- /dev/null +++ b/python/tests/test_page_map.py @@ -0,0 +1,67 @@ +"""Portable PageMap registration and citation transport coverage.""" + +from __future__ import annotations + +from docx_scalpel import ( + PageCitationRequest, + PageMap, + PageMapFragment, + PageMapPage, + PageMapRect, + open_session, +) + + +def test_register_and_consume_page_map(tour_plan_bytes: bytes) -> None: + with open_session(tour_plan_bytes) as session: + target = next( + anchor + for anchor in session.project().anchor_index.values() + if anchor.scope == "body" and anchor.kind == "tbl" + ) + request = PageCitationRequest( + document_version=session.get_version(), + renderer_fingerprint="python-page-map-v1", + ) + page_map = PageMap( + document_version=request.document_version, + renderer_fingerprint=request.renderer_fingerprint, + pages=( + PageMapPage( + page_number=1, + page_in_section=1, + width=612, + height=792, + page_name="docxodus-section-0", + section_index=0, + ), + ), + fragments=( + PageMapFragment( + fragment_id=f"p1-f0-{target.id}", + anchor_id=target.id, + fragment_index=0, + page_number=1, + geometry=PageMapRect(72, 90, 468, 120), + story="body", + ), + ), + ) + + assert session.register_page_map(page_map).success + assert session.get_page_map_status(request).availability == "available" + + citation = session.get_page_citation(target.id, request) + assert citation.availability == "available" + assert citation.fragments[0].page_number == 1 + + structural = session.find_by_kind("tbl", "body", request) + cited = next(anchor for anchor in structural if anchor.id == target.id) + assert cited.citation is not None + assert cited.citation.fragments[0].fragment_id == f"p1-f0-{target.id}" + + mismatch = session.get_page_map_status( + PageCitationRequest(request.document_version, "different-renderer") + ) + assert mismatch.availability == "unavailable" + assert mismatch.unavailable_reason == "renderer_fingerprint_mismatch" diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs index 69e03613..0777fe6b 100644 --- a/tools/mcp-server/Dispatcher.cs +++ b/tools/mcp-server/Dispatcher.cs @@ -33,6 +33,7 @@ internal static class Dispatcher "docxodus_close" => Close(store, args), "docxodus_get_content" => GetContent(store, args), "docxodus_preview" => Preview(store, args), + "docxodus_pagination" => Pagination(store, args), "docxodus_search" => Search(store, args), "docxodus_edit" => Edit(store, args), "docxodus_format" => Format(store, args), @@ -111,19 +112,22 @@ private static string GetContent(SessionStore store, JsonElement args) var session = Session(store, args); var format = Str(args, "format"); var anchorId = OptStr(args, "anchorId"); + var citation = DocxSessionJson.ParsePageCitationRequest(args); switch (format) { case "markdown": return anchorId is null ? DocxSessionOps.Project(session.Handle) - : DocxSessionOps.ProjectAnchor(session.Handle, anchorId, ProjectionDepth.SubtreeAndFollowingSiblings); + : DocxSessionOps.ProjectAnchor(session.Handle, anchorId, + ProjectionDepth.SubtreeAndFollowingSiblings, citation); case "text": { var projectionJson = anchorId is null ? DocxSessionOps.Project(session.Handle) - : DocxSessionOps.ProjectAnchor(session.Handle, anchorId, ProjectionDepth.SubtreeAndFollowingSiblings); + : DocxSessionOps.ProjectAnchor(session.Handle, anchorId, + ProjectionDepth.SubtreeAndFollowingSiblings, citation); using var doc = JsonDocument.Parse(projectionJson); var markdown = doc.RootElement.GetProperty("markdown").GetString() ?? string.Empty; return $"{{\"text\":{JsonRpcIo.JsonString(StripMarkdownSyntax(markdown))}}}"; @@ -185,14 +189,39 @@ private static string Preview(SessionStore store, JsonElement args) { var session = Session(store, args); var anchorId = OptStr(args, "anchorId"); + var citationRequest = DocxSessionJson.ParsePageCitationRequest(args); var html = anchorId is null ? DocxSessionOps.RenderHtml(session.Handle, "docx-", false, false, 1.0) : DocxSessionOps.RenderBlockHtml(session.Handle, anchorId, "docx-", false); + var citationJson = anchorId is not null && citationRequest is not null + ? DocxSessionOps.GetPageCitation(session.Handle, anchorId, citationRequest) + : null; return $"{{\"sessionId\":{JsonRpcIo.JsonString(session.Id)}" + (anchorId is null ? "" : $",\"anchorId\":{JsonRpcIo.JsonString(anchorId)}") + + (citationJson is null ? "" : $",\"citation\":{citationJson}") + + ",\"pageNavigation\":\"unavailable_continuous_preview\"" + $",\"html\":{JsonRpcIo.JsonString(html)}}}"; } + private static string Pagination(SessionStore store, JsonElement args) + { + var session = Session(store, args); + return Str(args, "action") switch + { + "register" => DocxSessionOps.RegisterPageMap( + session.Handle, + DocxSessionJson.ParsePageMap(Object(args, "pageMap")), + OptStr(args, "expectedRendererFingerprint")), + "status" => DocxSessionOps.GetPageMapStatus( + session.Handle, DocxSessionJson.ParsePageCitationRequest(args)), + "cite" => DocxSessionOps.GetPageCitation( + session.Handle, Str(args, "anchorId"), + DocxSessionJson.ParsePageCitationRequest(args) + ?? throw new McpToolException("cite requires citation {documentVersion, rendererFingerprint}")), + _ => throw new McpToolException("unknown pagination action"), + }; + } + /// /// Best-effort plain-text approximation of a markdown projection: unescapes the projector's /// backslash-escaped punctuation, then strips ATX heading markers, emphasis/strike/code @@ -226,18 +255,19 @@ private static string Search(SessionStore store, JsonElement args) var scope = ParseSearchScope(OptStr(args, "scope")); var maxResults = args.ValueKind == JsonValueKind.Object && args.TryGetProperty("maxResults", out var mr) && mr.ValueKind == JsonValueKind.Number ? mr.GetInt32() : (int?)null; + var citation = DocxSessionJson.ParsePageCitationRequest(args); string matchesJson = mode switch { "text" => DocxSessionOps.Grep( session.Handle, Regex.Escape(query), caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase, - scope, contextChars, WhitespaceMode.Preserve, ContextBoundary.Char), + scope, contextChars, WhitespaceMode.Preserve, ContextBoundary.Char, citation), "regex" => DocxSessionOps.Grep( session.Handle, query, caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase, - scope, contextChars, WhitespaceMode.Preserve, ContextBoundary.Char), - "kind" => DocxSessionOps.FindByKind(session.Handle, query, null), - "annotation" => DocxSessionOps.FindByAnnotation(session.Handle, query), - "bookmark" => DocxSessionOps.FindByBookmark(session.Handle, query), + scope, contextChars, WhitespaceMode.Preserve, ContextBoundary.Char, citation), + "kind" => DocxSessionOps.FindByKind(session.Handle, query, null, citation), + "annotation" => DocxSessionOps.FindByAnnotation(session.Handle, query, citation), + "bookmark" => DocxSessionOps.FindByBookmark(session.Handle, query, citation), _ => throw new McpToolException($"unknown search mode: {mode}"), }; @@ -852,6 +882,14 @@ private static string Str(JsonElement args, string name) args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + private static JsonElement Object(JsonElement args, string name) + { + if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.Object) + throw new McpToolException($"missing required object argument \"{name}\""); + return value; + } + private static int Int(JsonElement args, string name) { if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number) diff --git a/tools/mcp-server/README.md b/tools/mcp-server/README.md index 2925991b..1718f535 100644 --- a/tools/mcp-server/README.md +++ b/tools/mcp-server/README.md @@ -78,12 +78,13 @@ path stay as they are. ## Tool surface -Three lifecycle tools plus ten grouped-intent tools, each addressed by the anchor ids the +Three lifecycle tools plus thirteen grouped-intent tools, each addressed by the anchor ids the markdown projection and search tools return: | Tool | Purpose | |------|---------| | `docxodus_open` / `docxodus_save` / `docxodus_close` | Session lifecycle | +| `docxodus_pagination` | Register, inspect, or query an externally materialized PageMap | | `docxodus_get_content` | Read as markdown, HTML, plain text, block metadata, or document info | | `docxodus_search` | Find text (literal/regex), or blocks by kind/annotation/bookmark | | `docxodus_edit` | Insert/replace/delete text and blocks, split/merge paragraphs, undo/redo | @@ -118,6 +119,11 @@ them): `expectedVersion` and/or an anchor hash/exact text/range/kind/scope; replacement may also require `expectedMatchCount`. `docxodus_get_content` formats `version` and `check_preconditions` expose the read side. Batch-level and per-step guards use the same shape. +- **The inline MCP preview is continuous, not physically paginated.** It reports + `pageNavigation: "unavailable_continuous_preview"`; a registered PageMap can still supply an + exact page label, but navigation to a page box awaits the server-side paginated HTML substrate + tracked by #434. The server does not estimate or bundle a browser. See the + [PageMap contract](../../docs/architecture/page_map.md). ## License diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 915c542c..5b918ad0 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -10,7 +10,7 @@ namespace Docxodus.McpServer; internal sealed record ToolDefinition(string Name, string Description, string InputSchemaJson); /// -/// The tool surface this server advertises: three lifecycle tools (open/save/close) plus twelve +/// The tool surface this server advertises: three lifecycle tools (open/save/close) plus thirteen /// grouped-intent tools, each accepting an action discriminator and action-specific /// arguments. See docs/architecture/docx_agent_server.md for the full contract, the /// mapping of every action onto the underlying Docxodus API, and the documented capability gaps. @@ -66,6 +66,7 @@ internal static class ToolCatalog "sessionId": { "type": "string" }, "format": { "type": "string", "enum": ["markdown", "html", "text", "blocks", "info", "version", "check_preconditions"], "description": "markdown/text: projection; html: rendered HTML; blocks: metadata; info: version plus page/edit facts; version: monotonic document version; check_preconditions: read-only guard evaluation." }, "anchorId": { "type": "string", "description": "Optional scope/target anchor." }, + "citation": { "type": "object", "description": "Optional exact layout token {documentVersion, rendererFingerprint}; scoped markdown includes explicit pageCitations when supplied." }, "preconditions": { "type": "object", "description": "check_preconditions: expectedVersion and/or anchorId plus expectedContentHash, expectedText/expectedTextRange, expectedKind, expectedScope, or expectedMatchCount." } }, "required": ["sessionId", "format"] @@ -73,17 +74,35 @@ internal static class ToolCatalog """), new ToolDefinition( "docxodus_preview", - "Render a session's document (or a single block) to HTML for the host's inline preview widget. The markup travels in the result's _meta for the widget only; the model-visible result is a short summary. Call again after edits to refresh the rendered view.", + "Render a session's document (or a single block) to HTML for the host's inline preview widget. This MCP preview is continuous until #434 supplies paginated HTML, so citations show a page label and highlight the source anchor but cannot navigate a physical page box. The markup travels in the result's _meta for the widget only; call again after edits to refresh it.", """ { "type": "object", "properties": { "sessionId": { "type": "string" }, "anchorId": { "type": "string", "description": "Optional. Render just this block (any addressable anchor, including hdr*/ftr* scopes) instead of the whole document. Whole-document renders include the converter's stylesheet; single-block renders are bare markup." } + ,"citation": { "type": "object", "description": "Optional exact layout token. When valid, the widget navigates/highlights the cited page fragment if the returned HTML contains that pagination substrate." } }, "required": ["sessionId"] } """), + new ToolDefinition( + "docxodus_pagination", + "Register or consume a browser-materialized PageMap. Core never estimates page numbers; unavailable, continuous, stale, and renderer-mismatched layouts are explicit.", + """ + { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "action": { "type": "string", "enum": ["register", "status", "cite"] }, + "pageMap": { "type": "object", "description": "register: versioned PageMap produced after browser pagination." }, + "expectedRendererFingerprint": { "type": "string", "description": "register: optional independently expected fingerprint; mismatch rejects the map." }, + "anchorId": { "type": "string", "description": "cite: canonical kind:scope:unid anchor." }, + "citation": { "type": "object", "description": "status/cite: exact {documentVersion, rendererFingerprint} layout token." } + }, + "required": ["sessionId", "action"] + } + """), new ToolDefinition( "docxodus_search", "Find text or structural nodes in a session's document. Returns anchor ids usable directly as the anchorId/cellAnchorId argument of every other tool.", @@ -98,6 +117,7 @@ internal static class ToolCatalog "contextChars": { "type": "integer", "description": "Characters of context captured on each side of a text/regex match. Default 80." }, "scope": { "type": "string", "enum": ["body", "headers", "footers", "header_footer", "all"], "description": "text/regex only: package stories to search. Default body preserves existing behavior; headers/footers cover every hdr*/ftr* part, header_footer combines them, and all includes body, running stories, notes, and comments." }, "maxResults": { "type": "integer", "description": "Cap the number of matches returned. Default unlimited." } + ,"citation": { "type": "object", "description": "Optional exact {documentVersion, rendererFingerprint}; every search mode attaches an explicit citation result to each match." } }, "required": ["sessionId", "mode", "query"] } diff --git a/tools/mcp-server/UiResources.cs b/tools/mcp-server/UiResources.cs index a34705dc..5b93a1e8 100644 --- a/tools/mcp-server/UiResources.cs +++ b/tools/mcp-server/UiResources.cs @@ -94,6 +94,10 @@ public static string WrapToolResult(string toolName, string resultJson, bool isE .Append(JsonRpcIo.JsonString(sessionId!)); if (anchorId is not null) structured.Append(",\"anchorId\":").Append(JsonRpcIo.JsonString(anchorId)); + if (root.TryGetProperty("citation", out var citation)) + structured.Append(",\"citation\":").Append(citation.GetRawText()); + if (root.TryGetProperty("pageNavigation", out var pageNavigation)) + structured.Append(",\"pageNavigation\":").Append(pageNavigation.GetRawText()); structured.Append(",\"htmlLength\":").Append(html.Length).Append('}'); // content text = the structuredContent summary, NOT the HTML: the model needs to @@ -145,6 +149,8 @@ public static string WrapToolResult(string toolName, string resultJson, bool isE border-radius: 4px; background: #fff; cursor: pointer; } #dxo-refresh:hover { background: #f0f0f0; } #dxo-content { padding: 16px 20px; overflow: auto; } + .dxo-cited-fragment { outline: 3px solid #f4b400 !important; + outline-offset: 2px; background-color: rgba(255, 235, 59, .18) !important; } @@ -157,7 +163,8 @@ public static string WrapToolResult(string toolName, string resultJson, bool isE