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..916f9726 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -305,6 +305,84 @@ 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("available_registered_map", + preview.GetProperty("pageNavigation").GetString()); + Assert.Equal(1, preview.GetProperty("citation").GetProperty("fragments")[0] + .GetProperty("pageNumber").GetInt32()); + Assert.Equal(612, preview.GetProperty("citation").GetProperty("pages")[0] + .GetProperty("width").GetDouble()); + Assert.Contains("pagination-staging", preview.GetProperty("html").GetString()); + + 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 +1032,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) { @@ -968,6 +1046,48 @@ public void MCP100_ToolCatalog_HasFifteenDistinctNamedToolsWithValidSchemas() } } + [Fact] + public void MCP101_PageMapSchemas_DescribeStrictTokensAndActionRequirements() + { + static void AssertCitationSchema(JsonElement schema) + { + Assert.False(schema.GetProperty("additionalProperties").GetBoolean()); + var required = schema.GetProperty("required").EnumerateArray() + .Select(value => value.GetString()).ToArray(); + Assert.Contains("documentVersion", required); + Assert.Contains("rendererFingerprint", required); + Assert.Equal("integer", schema.GetProperty("properties") + .GetProperty("documentVersion").GetProperty("type").GetString()); + Assert.Equal(1, schema.GetProperty("properties") + .GetProperty("rendererFingerprint").GetProperty("minLength").GetInt32()); + } + + foreach (var toolName in new[] { "docxodus_get_content", "docxodus_preview", "docxodus_search" }) + { + var tool = Assert.Single(ToolCatalog.Tools, item => item.Name == toolName); + using var schema = JsonDocument.Parse(tool.InputSchemaJson); + AssertCitationSchema(schema.RootElement.GetProperty("properties").GetProperty("citation")); + } + + var pagination = Assert.Single(ToolCatalog.Tools, item => item.Name == "docxodus_pagination"); + using var paginationSchema = JsonDocument.Parse(pagination.InputSchemaJson); + var root = paginationSchema.RootElement; + AssertCitationSchema(root.GetProperty("properties").GetProperty("citation")); + var pageMap = root.GetProperty("properties").GetProperty("pageMap"); + Assert.False(pageMap.GetProperty("additionalProperties").GetBoolean()); + Assert.Equal(1, pageMap.GetProperty("properties").GetProperty("schemaVersion") + .GetProperty("const").GetInt32()); + Assert.False(pageMap.GetProperty("properties").GetProperty("fragments") + .GetProperty("items").GetProperty("additionalProperties").GetBoolean()); + var variants = root.GetProperty("oneOf").EnumerateArray().ToArray(); + Assert.Contains(variants, variant => variant.GetProperty("properties").GetProperty("action") + .GetProperty("const").GetString() == "register" + && variant.GetProperty("required").EnumerateArray().Any(v => v.GetString() == "pageMap")); + Assert.Contains(variants, variant => variant.GetProperty("properties").GetProperty("action") + .GetProperty("const").GetString() == "cite" + && variant.GetProperty("required").EnumerateArray().Any(v => v.GetString() == "citation")); + } + [Fact] public void MCP139_ToolCatalog_AdvertisesHeaderFooterCreateAndSearchScope() { @@ -1031,6 +1151,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","pages":[{"pageNumber":3,"pageInSection":1,"width":612,"height":792,"pageName":"docxodus-section-0"}],"fragments":[{"pageNumber":3}]},"pageNavigation":"available_registered_map"}""", + isError: false)).GetProperty("structuredContent"); + Assert.Equal(3, cited.GetProperty("citation").GetProperty("fragments")[0] + .GetProperty("pageNumber").GetInt32()); + Assert.Equal("available_registered_map", 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 +1185,9 @@ 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.Contains("available_registered_map", htmlText); + Assert.Contains("materializeCitationPage", 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..8c1448f4 --- /dev/null +++ b/Docxodus.Tests/PageMapSourceIdentityTests.cs @@ -0,0 +1,238 @@ +#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 first.\n\nInline second.").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); + var commentParagraphs = inlineProjection.AnchorIndex.Values + .Where(target => target.Anchor.Scope == "cmt" && target.Anchor.Kind == "p") + .Select(target => target.Anchor.Id) + .ToArray(); + Assert.Equal(2, commentParagraphs.Length); + Assert.All(commentParagraphs, anchor => Assert.Contains( + inlineHtml.DescendantsAndSelf(), element => + (string?)element.Attribute("data-source-anchor-id") == anchor)); + } + + [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); + } + + [Fact] + public void PM102_PaginatedStatelessHtmlAlwaysCarriesCanonicalIdentityAndStagesComments() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = session.Project().AnchorIndex.Values.First(target => + target.Anchor.Scope == "body" && target.Anchor.Kind == "p"); + Assert.True(session.AddComment( + body.Anchor.Id, new CharSpan(0, 5), "Reviewer", "First.\n\nSecond.").Success); + + XElement Convert(CommentRenderMode mode) => XElement.Parse(HtmlConversionOps.ConvertToHtml( + session, + new HtmlConversionOptions + { + StampAnchors = false, + FabricateCssClasses = false, + PaginationMode = (int)PaginationMode.Paginated, + CommentRenderMode = (int)mode, + })); + + var endnoteHtml = Convert(CommentRenderMode.EndnoteStyle); + Assert.DoesNotContain(endnoteHtml.DescendantsAndSelf().Attributes("data-anchor"), _ => true); + Assert.Contains(endnoteHtml.DescendantsAndSelf().Attributes("data-source-anchor-id"), _ => true); + var staging = endnoteHtml.Descendants().Single(element => + (string?)element.Attribute("id") == "pagination-staging"); + var finalSection = staging.Descendants().Last(element => + element.Attribute("data-section-index") is not null); + Assert.Contains(finalSection.Descendants(), element => + ((string?)element.Attribute("class"))?.Contains("comments-section", StringComparison.Ordinal) == true); + + var marginHtml = Convert(CommentRenderMode.Margin); + var marginStaging = marginHtml.Descendants().Single(element => + (string?)element.Attribute("id") == "pagination-staging"); + var registry = marginStaging.Descendants().Single(element => + (string?)element.Attribute("id") == "pagination-comment-margin-registry"); + Assert.Contains(registry.DescendantsAndSelf().Attributes("data-source-anchor-id"), _ => true); + Assert.DoesNotContain(marginStaging.Descendants() + .Where(element => element.Attribute("data-section-index") is not null) + .SelectMany(element => element.Descendants()), element => + (string?)element.Attribute("id") == "pagination-comment-margin-registry"); + } + + [Fact] + public void PM103_InlineTableCommentIdentitiesStayInsideTheTablePresentation() + { + using var session = new DocxSession(DocxSessionTests.BuildDS003_TableWithCells()); + var cell = session.Project().AnchorIndex.Values.First(target => + target.Anchor.Kind == "p" && target.TextPreview == "R0C0"); + Assert.True(session.AddComment( + cell.Anchor.Id, new CharSpan(0, 2), "Reviewer", "First.\n\nSecond.").Success); + var commentIds = session.Project().AnchorIndex.Values + .Where(target => target.Anchor.Scope == "cmt" && target.Anchor.Kind is "cmt" or "p") + .Select(target => target.Anchor.Id) + .ToHashSet(StringComparer.Ordinal); + + var html = XElement.Parse(HtmlConversionOps.ConvertToHtml(session, new HtmlConversionOptions + { + StampAnchors = false, + FabricateCssClasses = false, + PaginationMode = (int)PaginationMode.Paginated, + CommentRenderMode = (int)CommentRenderMode.Inline, + })); + var tableCell = html.Descendants().First(element => element.Name.LocalName == "td"); + var presentedCommentIds = tableCell.DescendantsAndSelf() + .Attributes("data-source-anchor-id") + .Select(attribute => attribute.Value) + .Where(commentIds.Contains) + .ToHashSet(StringComparer.Ordinal); + Assert.Equal(commentIds, presentedCommentIds); + } +} diff --git a/Docxodus.Tests/PageMapTests.cs b/Docxodus.Tests/PageMapTests.cs new file mode 100644 index 00000000..8b4eb0aa --- /dev/null +++ b/Docxodus.Tests/PageMapTests.cs @@ -0,0 +1,382 @@ +#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 System.Text.Json; +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)); + Assert.Equal(new[] { 1, 2 }, citation.Pages.Select(page => page.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); + } + + [Fact] + public void PM008_PageMapAndCitationWireSchemasRejectMissingOrMistypedRequiredFields() + { + const string valid = """ + { + "schemaVersion":1, + "mode":"paginated", + "availability":"available", + "documentVersion":0, + "rendererFingerprint":"renderer", + "pages":[{ + "pageNumber":1,"pageInSection":1,"width":612,"height":792, + "sectionIndex":0,"pageName":"docxodus-section-0" + }], + "fragments":[{ + "fragmentId":"f", "anchorId":"p:body:u", "fragmentIndex":0, + "pageNumber":1, "geometry":{"x":0,"y":0,"width":1,"height":1}, + "story":"body", "inTableCell":false + }] + } + """; + + Assert.Throws(() => DocxSessionJson.ParsePageMap( + valid.Replace("\"schemaVersion\":1,", string.Empty, StringComparison.Ordinal))); + Assert.Throws(() => DocxSessionJson.ParsePageMap( + valid.Replace("\"pageInSection\":1,", string.Empty, StringComparison.Ordinal))); + Assert.Throws(() => DocxSessionJson.ParsePageMap( + valid.Replace("\"sectionIndex\":0", "\"sectionIndex\":\"0\"", StringComparison.Ordinal))); + Assert.Throws(() => DocxSessionJson.ParsePageMap( + valid.Replace(", \"inTableCell\":false", string.Empty, StringComparison.Ordinal))); + Assert.Throws(() => DocxSessionJson.ParsePageMap( + valid.Replace("\"inTableCell\":false", "\"inTableCell\":\"false\"", StringComparison.Ordinal))); + Assert.Throws(() => DocxSessionJson.ParsePageMap( + valid.Replace("\"schemaVersion\":1", "\"schemaVersion\":1,\"schemaVerzion\":1", + StringComparison.Ordinal))); + Assert.Throws(() => DocxSessionJson.ParsePageMap( + valid.Replace("\"width\":612", "\"width\":612,\"widht\":612", + StringComparison.Ordinal))); + Assert.Throws(() => DocxSessionJson.ParsePageMap( + valid.Replace("\"x\":0", "\"x\":0,\"left\":0", StringComparison.Ordinal))); + + using var malformed = JsonDocument.Parse("{\"citation\":\"current page\"}"); + Assert.Throws(() => + DocxSessionJson.ParsePageCitationRequest(malformed.RootElement)); + using var missingCitationField = JsonDocument.Parse( + "{\"citation\":{\"documentVersion\":0}}"); + Assert.Throws(() => + DocxSessionJson.ParsePageCitationRequest(missingCitationField.RootElement)); + using var extraCitationField = JsonDocument.Parse( + "{\"citation\":{\"documentVersion\":0,\"rendererFingerprint\":\"renderer\",\"page\":1}}"); + Assert.Throws(() => + DocxSessionJson.ParsePageCitationRequest(extraCitationField.RootElement)); + } + + [Fact] + public void PM009_InlineCommentDefinitionsMayMapInsideTheirBodyTablePresentation() + { + using var tableSession = new DocxSession(DocxSessionTests.BuildDS003_TableWithCells()); + var cellParagraph = tableSession.Project().AnchorIndex.Values.First(target => + target.Anchor.Kind == "p" && target.TextPreview == "R0C0"); + Assert.True(tableSession.AddComment( + cellParagraph.Anchor.Id, new CharSpan(0, 2), "Reviewer", "Cell comment").Success); + var commentAnchors = tableSession.Project().AnchorIndex.Values + .Where(target => target.Anchor.Scope == "cmt" && target.Anchor.Kind is "cmt" or "p") + .Select(target => target.Anchor.Id) + .ToArray(); + Assert.Equal(2, commentAnchors.Length); + Assert.True(tableSession.RegisterPageMap(AvailableMap( + tableSession, + commentAnchors.Select(anchor => Fragment( + anchor, story: PageMapStory.Comment, inTableCell: true)).ToArray())).Success); + + using var bodySession = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var bodyParagraph = bodySession.Project().AnchorIndex.Values.First(target => + target.Anchor.Scope == "body" && target.Anchor.Kind == "p"); + Assert.True(bodySession.AddComment( + bodyParagraph.Anchor.Id, new CharSpan(0, 2), "Reviewer", "Body comment").Success); + var bodyComment = bodySession.Project().AnchorIndex.Values.Single(target => + target.Anchor.Scope == "cmt" && target.Anchor.Kind == "cmt"); + Assert.Equal(PageMapRegistrationError.InvalidMap, + bodySession.RegisterPageMap(AvailableMap(bodySession, new[] + { + Fragment(bodyComment.Anchor.Id, story: PageMapStory.Comment, inTableCell: true), + })).Error); + } + + [Fact] + public void PM010_AvailablePaginatedMapCannotRegisterWithoutFragments() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + + var result = session.RegisterPageMap(AvailableMap( + session, Array.Empty(), new[] { Page() })); + + Assert.False(result.Success); + Assert.Equal(PageMapRegistrationError.InvalidMap, result.Error); + Assert.Contains("at least one page and fragment", result.Message, StringComparison.Ordinal); + } +} diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 8e8b290c..6a618795 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,302 @@ 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 || pageMap.Fragments.Count == 0) + { + return Fail(PageMapRegistrationError.InvalidMap, + "an available paginated PageMap must contain at least one page and fragment"); + } + + 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); + // A comment's canonical source lives in comments.xml, while its inline presentation + // lives at the referenced range in the main story. A true table flag therefore must + // be proven from a live body-side marker; false also validly describes the definition, + // endnote-style, margin, or an out-of-table inline presentation. + if (fragment.Story == PageMapStory.Comment + && fragment.InTableCell + && !CommentHasTableCellPresentation(element)) + return Fail(PageMapRegistrationError.InvalidMap, + $"PageMap comment has no table-cell presentation: {fragment.AnchorId}"); + if (fragment.Story != PageMapStory.Comment + && 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); + var citedPageNumbers = fragments.Select(fragment => fragment.PageNumber).ToHashSet(); + var pages = _registeredPageMap.Pages + .Where(page => citedPageNumbers.Contains(page.PageNumber)) + .OrderBy(page => page.PageNumber) + .ToArray(); + return new PageCitation + { + AnchorId = anchorId, + Availability = PageMapAvailability.Available, + DocumentVersion = _version, + RendererFingerprint = request.RendererFingerprint, + Pages = pages, + 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 bool CommentHasTableCellPresentation(XElement? source) + { + var commentId = (string?)source?.AncestorsAndSelf(W.comment).FirstOrDefault()?.Attribute(W.id); + var mainRoot = _doc?.MainDocumentPart?.GetXDocument().Root; + if (commentId is null || mainRoot is null) return false; + return mainRoot.Descendants() + .Where(element => element.Name == W.commentRangeStart + || element.Name == W.commentRangeEnd + || element.Name == W.commentReference) + .Any(element => (string?)element.Attribute(W.id) == commentId + && element.Ancestors(W.tc).Any()); + } + + 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 +1798,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 +1867,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 +2713,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 +2796,9 @@ public IReadOnlyList Grep( ContextBefore = ctxBefore, ContextAfter = ctxAfter, Groups = groups, + Citation = citationRequest is null + ? null + : GetPageCitation(target.Anchor.Id, citationRequest), }); } } @@ -2515,7 +2834,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 +2958,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 +3004,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 +3015,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 +3034,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 +3045,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 +3091,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 +3101,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 +3112,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 +3123,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 +3136,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 +3505,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 +3516,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..89ca521b 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -20,6 +20,178 @@ 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"); + EnsureOnlyProperties(root, "PageMap", "schemaVersion", "mode", "availability", + "documentVersion", "rendererFingerprint", "pages", "fragments"); + + var pages = new List(); + var pagesElement = RequiredProperty(root, "pages", JsonValueKind.Array, "PageMap"); + foreach (var page in pagesElement.EnumerateArray()) + { + if (page.ValueKind != JsonValueKind.Object) + throw new FormatException("PageMap pages must be objects"); + EnsureOnlyProperties(page, "PageMap page", "pageNumber", "pageInSection", "width", + "height", "sectionIndex", "pageName"); + + int? sectionIndex = null; + if (page.TryGetProperty("sectionIndex", out var sectionIndexElement)) + { + if (sectionIndexElement.ValueKind != JsonValueKind.Number + || !sectionIndexElement.TryGetInt32(out var parsedSectionIndex)) + throw new FormatException("PageMap page sectionIndex must be an integer when supplied"); + sectionIndex = parsedSectionIndex; + } + + pages.Add(new PageMapPage + { + PageNumber = RequiredInt32(page, "pageNumber", "PageMap page"), + PageInSection = RequiredInt32(page, "pageInSection", "PageMap page"), + Width = RequiredDouble(page, "width", "PageMap page"), + Height = RequiredDouble(page, "height", "PageMap page"), + SectionIndex = sectionIndex, + PageName = RequiredString(page, "pageName", "PageMap page"), + }); + } + + var fragments = new List(); + var fragmentsElement = RequiredProperty(root, "fragments", JsonValueKind.Array, "PageMap"); + foreach (var fragment in fragmentsElement.EnumerateArray()) + { + if (fragment.ValueKind != JsonValueKind.Object) + throw new FormatException("PageMap fragments must be objects"); + EnsureOnlyProperties(fragment, "PageMap fragment", "fragmentId", "anchorId", + "fragmentIndex", "pageNumber", "geometry", "story", "inTableCell"); + var geometry = RequiredProperty(fragment, "geometry", JsonValueKind.Object, "PageMap fragment"); + EnsureOnlyProperties(geometry, "PageMap geometry", "x", "y", "width", "height"); + fragments.Add(new PageMapFragment + { + FragmentId = RequiredString(fragment, "fragmentId", "PageMap fragment"), + AnchorId = RequiredString(fragment, "anchorId", "PageMap fragment"), + FragmentIndex = RequiredInt32(fragment, "fragmentIndex", "PageMap fragment"), + PageNumber = RequiredInt32(fragment, "pageNumber", "PageMap fragment"), + Geometry = new PageMapRect( + RequiredDouble(geometry, "x", "PageMap geometry"), + RequiredDouble(geometry, "y", "PageMap geometry"), + RequiredDouble(geometry, "width", "PageMap geometry"), + RequiredDouble(geometry, "height", "PageMap geometry")), + Story = ParsePageMapStory(RequiredString(fragment, "story", "PageMap fragment")), + InTableCell = RequiredBoolean(fragment, "inTableCell", "PageMap fragment"), + }); + } + + return new PageMap + { + SchemaVersion = RequiredInt32(root, "schemaVersion", "PageMap"), + Mode = ParsePageMapMode(RequiredString(root, "mode", "PageMap")), + Availability = ParsePageMapAvailability(RequiredString(root, "availability", "PageMap")), + DocumentVersion = RequiredInt64(root, "documentVersion", "PageMap"), + RendererFingerprint = RequiredString(root, "rendererFingerprint", "PageMap"), + Pages = pages, + Fragments = fragments, + }; + } + + public static PageCitationRequest? ParsePageCitationRequest(JsonElement root, string key = "citation") + { + if (!root.TryGetProperty(key, out var value)) + return null; + if (value.ValueKind != JsonValueKind.Object) + throw new FormatException($"{key} must be a JSON object"); + EnsureOnlyProperties(value, key, "documentVersion", "rendererFingerprint"); + return new PageCitationRequest( + RequiredInt64(value, "documentVersion", key), + RequiredString(value, "rendererFingerprint", key)); + } + + private static JsonElement RequiredProperty( + JsonElement root, string name, JsonValueKind kind, string owner) + { + if (!root.TryGetProperty(name, out var value) || value.ValueKind != kind) + throw new FormatException($"{owner} requires {name} with JSON type {kind}"); + return value; + } + + private static void EnsureOnlyProperties(JsonElement root, string owner, params string[] allowed) + { + foreach (var property in root.EnumerateObject()) + { + if (System.Array.IndexOf(allowed, property.Name) < 0) + throw new FormatException($"{owner} contains unknown property {property.Name}"); + } + } + + private static string RequiredString(JsonElement root, string name, string owner) => + RequiredProperty(root, name, JsonValueKind.String, owner).GetString()!; + + private static bool RequiredBoolean(JsonElement root, string name, string owner) + { + if (!root.TryGetProperty(name, out var value) + || value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new FormatException($"{owner} requires {name} with JSON type Boolean"); + return value.GetBoolean(); + } + + private static int RequiredInt32(JsonElement root, string name, string owner) + { + if (!root.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.Number + || !value.TryGetInt32(out var result)) + throw new FormatException($"{owner} requires integer {name}"); + return result; + } + + private static long RequiredInt64(JsonElement root, string name, string owner) + { + if (!root.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.Number + || !value.TryGetInt64(out var result)) + throw new FormatException($"{owner} requires integer {name}"); + return result; + } + + private static double RequiredDouble(JsonElement root, string name, string owner) + { + if (!root.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.Number + || !value.TryGetDouble(out var result)) + throw new FormatException($"{owner} requires numeric {name}"); + return result; + } + + 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 +584,7 @@ public static TableRowHeightRule ParseTableRowHeightRule(string? rule) => KindFilter = TryGetString(root, "kindFilter", null), Scopes = scopes, ScopeFilter = TryGetString(root, "scopeFilter", null), + CitationRequest = ParsePageCitationRequest(root), }; } @@ -635,6 +808,102 @@ 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(",\"pages\":["); + for (int i = 0; i < citation.Pages.Count; i++) + { + if (i > 0) sb.Append(','); + AppendPageMapPage(sb, citation.Pages[i]); + } + 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 AppendPageMapPage(StringBuilder sb, PageMapPage page) + { + sb.Append("{\"pageNumber\":").Append(page.PageNumber) + .Append(",\"pageInSection\":").Append(page.PageInSection) + .Append(",\"width\":").Append(Invariant(page.Width)) + .Append(",\"height\":").Append(Invariant(page.Height)); + if (page.SectionIndex is { } sectionIndex) + sb.Append(",\"sectionIndex\":").Append(sectionIndex); + sb.Append(",\"pageName\":").Append(JsonString(page.PageName)).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 +1033,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 +1078,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 +1160,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 +1203,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 +1399,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..97a0adb1 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. @@ -96,11 +85,13 @@ public static string ConvertToHtml(byte[] docxBytes, HtmlConversionOptions optio memoryStream.Position = 0; using var wordDoc = WordprocessingDocument.Open(memoryStream, true); - if (options.StampAnchors) - AssignAnchorUnids(wordDoc); - var renderComments = options.CommentRenderMode >= 0; bool renderPagination = options.PaginationMode == (int)PaginationMode.Paginated; + // PageMap source identity is required for every paginated render, including the + // stateless viewer's default stampAnchors=false path. Bare editable data-anchor stamps + // remain opt-in; canonical kind:scope:unid identity does not. + if (options.StampAnchors || renderPagination) + AssignAnchorUnids(wordDoc); // The paginated React viewer injects this document HTML into its capture host. A body margin // therefore applies to the HOST body as well as the document staging tree and makes every fixed-size // page box overflow onto a second printed page. Keep the comfortable standalone-document margin, but @@ -143,6 +134,7 @@ public static string ConvertToHtml(byte[] docxBytes, HtmlConversionOptions optio IncludeUnsupportedContentMetadata = true, DocumentLanguage = options.DocumentLanguage, StampAnchors = options.StampAnchors, + StampCanonicalSourceAnchors = options.StampAnchors || renderPagination, // 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..158a4664 --- /dev/null +++ b/Docxodus/PageMap.cs @@ -0,0 +1,143 @@ +#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; } + /// Physical descriptors for the pages referenced by . + public IReadOnlyList Pages { get; init; } = Array.Empty(); + 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..9967cc50 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(); } @@ -644,6 +659,13 @@ internal class CommentTracker /// IDs of comments that have been referenced in the document (for rendering order). /// public List ReferencedCommentIds { get; } = new List(); + + /// + /// Comment ranges for which at least one visible run was emitted. A valid zero-width + /// comment has adjacent start/end markers and therefore never reaches ConvertRun while + /// open; its reference marker becomes the visible presentation anchor instead. + /// + public HashSet RenderedRangeIds { get; } = new HashSet(); } /// @@ -817,6 +839,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 +994,33 @@ public static XElement ConvertToHtml(WordprocessingDocument wordDoc, WmlToHtmlCo } rootElement.AddAnnotation(footnoteTracker); + if (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); @@ -2685,7 +2738,9 @@ private static object ConvertToHtmlTransform(WordprocessingDocument wordDoc, var mainContent = CreateSectionDivs(wordDoc, settings, element); // For margin mode, wrap content in a flex container with margin column - if (settings.RenderComments && settings.CommentRenderMode == CommentRenderMode.Margin) + if (settings.RenderComments + && settings.CommentRenderMode == CommentRenderMode.Margin + && settings.RenderPagination != PaginationMode.Paginated) { var prefix = settings.CommentCssClassPrefix ?? "comment-"; var tracker = GetCommentTracker(element); @@ -2745,7 +2800,9 @@ private static object ConvertToHtmlTransform(WordprocessingDocument wordDoc, } // Add comments section if enabled (EndnoteStyle mode) - if (settings.RenderComments && settings.CommentRenderMode == CommentRenderMode.EndnoteStyle) + if (settings.RenderComments + && settings.CommentRenderMode == CommentRenderMode.EndnoteStyle + && settings.RenderPagination != PaginationMode.Paginated) { var tracker = GetCommentTracker(element); if (tracker != null) @@ -3577,9 +3634,26 @@ private static XElement RenderFootnoteItem(WordprocessingDocument wordDoc, lastParagraph.Add(new XText(" "), backref); } + // The browser flattens safe paginated endnote paragraphs into ordinary page-flow + // blocks. Carry the owning endnote identity inside every paragraph so its range + // clones retain both the p:en and en:en identities when a long paragraph splits. + if (noteType == "en" && settings.RenderPagination == PaginationMode.Paginated) + { + foreach (var paragraph in content.OfType() + .Where(e => e.Name == Xhtml.p)) + { + var sourceIdentity = SourceAnchorIdentityAttribute(settings, noteElement); + if (sourceIdentity == null) continue; + var nodes = paragraph.Nodes().ToList(); + paragraph.RemoveNodes(); + paragraph.Add(new XElement(Xhtml.span, sourceIdentity, nodes)); + } + } + 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 +3736,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 +4117,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() }; } @@ -4219,6 +4295,18 @@ private static object ProcessCommentReference(WmlToHtmlConverterSettings setting new XAttribute("id", $"comment-ref-{id}"), new XAttribute("class", prefix + "marker")); + var isCollapsedRange = !tracker.RenderedRangeIds.Contains(id.Value); + if (isCollapsedRange) + { + // A point comment has no highlighted run from which pagination can discover its + // owning page. Make the already-visible reference marker that presentation point. + // Margin mode uses data-comment-id to select the page-owned note clone; inline mode + // additionally carries the comment-story identities used by PageMap/search. + marker.Add(new XAttribute("data-comment-id", id.Value.ToString())); + if (settings.CommentRenderMode == CommentRenderMode.Inline && comment != null) + marker.Add(SourceAnchorIdentityAttribute(settings, comment.SourceElement)); + } + if (comment != null && settings.IncludeCommentMetadata && comment.Author != null) { marker.Add(new XAttribute("title", $"Comment by {comment.Author}")); @@ -4233,7 +4321,19 @@ private static object ProcessCommentReference(WmlToHtmlConverterSettings setting }; marker.AddAnnotation(style); - return marker; + XElement presentation = marker; + if (isCollapsedRange && settings.CommentRenderMode == CommentRenderMode.Inline + && comment != null) + { + foreach (var paragraph in comment.ContentParagraphs) + { + var identity = SourceAnchorIdentityAttribute(settings, paragraph); + if (identity != null) + presentation = new XElement(Xhtml.span, identity, presentation); + } + } + + return presentation; } private static XElement RenderCommentsSection(WordprocessingDocument wordDoc, @@ -4272,7 +4372,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 +4426,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 +4479,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 +4533,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 +5005,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 +5211,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 +5281,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); @@ -5395,6 +5502,47 @@ private static object CreateSectionDivs(WordprocessingDocument wordDoc, WmlToHtm } } + // Margin comments are selectable presentation stories, like footnotes and + // headers/footers: keep one hidden source registry in staging and let the + // paginator clone only the comments referenced by each page into that page's + // side margin. Putting the column in the last flow section would turn margin + // notes into ordinary document-end body content. + if (settings.RenderComments + && settings.CommentRenderMode == CommentRenderMode.Margin) + { + var tracker = GetCommentTracker(element); + if (tracker != null) + { + var commentPrefix = settings.CommentCssClassPrefix ?? "comment-"; + var marginRegistry = RenderMarginCommentsColumn( + wordDoc, settings, tracker, commentPrefix); + if (marginRegistry.HasElements) + { + marginRegistry.Add(new XAttribute( + "id", "pagination-comment-margin-registry")); + marginRegistry.Add(new XAttribute("style", "display: none;")); + stagingContent.Add(marginRegistry); + } + } + } + + // Endnote-style comments are document-end content just like endnotes. In a + // paginated render they must live inside the final section wrapper; a body-level + // sibling of #pagination-staging is invisible to PaginationEngine and cannot + // produce PageMap fragments. + if (settings.RenderComments + && settings.CommentRenderMode == CommentRenderMode.EndnoteStyle + && divList.Count > 0) + { + var tracker = GetCommentTracker(element); + if (tracker != null) + { + var commentsSection = RenderCommentsSection(wordDoc, settings, tracker); + if (commentsSection != null) + divList[divList.Count - 1].Add(commentsSection); + } + } + // Add section content stagingContent.AddRange(divList); @@ -5471,6 +5619,15 @@ private enum BorderType * */ + private static XAttribute? SourceAnchorIdentityAttribute( + WmlToHtmlConverterSettings settings, XElement source) + { + if (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 +5638,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 +5672,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 +5687,7 @@ private static object ConvertParagraph(WordprocessingDocument wordDoc, WmlToHtml rtl, firstMark, anchorAttr, + sourceAnchorAttr, txElementsPrecedingTab, ConvertContentThatCanContainFields(wordDoc, settings, elementsSucceedingTab)); ApplyAutomaticLineSpacingToInlineContent(paraElement, style); @@ -6094,6 +6254,7 @@ private static object ConvertRun(WordprocessingDocument wordDoc, WmlToHtmlConver // For each open comment range, wrap the content foreach (var commentId in tracker.OpenRanges.OrderBy(id => id)) { + tracker.RenderedRangeIds.Add(commentId); var highlightSpan = new XElement(Xhtml.span, new XAttribute("class", prefix + "highlight"), new XAttribute("data-comment-id", commentId.ToString())); @@ -6103,6 +6264,21 @@ private static object ConvertRun(WordprocessingDocument wordDoc, WmlToHtmlConver { if (tracker.Comments.TryGetValue(commentId, out var comment)) { + highlightSpan.Add(SourceAnchorIdentityAttribute( + settings, comment.SourceElement)); + + // Inline mode presents the comment through its highlighted range + // rather than through a separate body. Map each comment paragraph + // to that same visible range so p:cmt scoped searches have an exact + // presentation fragment instead of silently missing from the map. + foreach (var commentParagraph in comment.ContentParagraphs) + { + var paragraphIdentity = SourceAnchorIdentityAttribute( + settings, commentParagraph); + if (paragraphIdentity != null) + content = new XElement(Xhtml.span, paragraphIdentity, content); + } + // 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..a07f8039 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -80,6 +80,13 @@ export type { ListMembership, MarkdownPatch, NumberFormat, + PageCitation, + PageCitationFragment, + PageCitationPage, + PageCitationRequest, + PageCitationUnavailableReason, + PageMapRegistrationResult, + PageMapStatus, PageNumberField, PageNumberingOp, ParagraphBorderEdge, @@ -171,9 +178,23 @@ export type { PageInfo, PaginationResult, PaginationOptions, + PageMap, + PageMapPage, + PageMapFragment, + PageMapRect, + PageMapMode, + PageMapAvailability, + PageMapStory, + PageCitationNavigation, } from "./pagination.js"; -export { PaginationEngine, paginateHtml } from "./pagination.js"; +export { + PaginationEngine, + clearPageCitationHighlight, + 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 +1807,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..df0cf9bd 100644 --- a/npm/src/pagination.ts +++ b/npm/src/pagination.ts @@ -118,6 +118,182 @@ 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"; +} + +interface PageCitationHighlightState { + target: HTMLElement; + highlightClass?: string; + addedClass?: boolean; + inlineStyles?: Array<{ name: string; value: string; priority: string }>; +} + +const activePageCitationHighlights = new WeakMap(); + +/** Remove the citation highlight previously applied within this paginated root. */ +export function clearPageCitationHighlight(root: ParentNode): void { + const active = activePageCitationHighlights.get(root); + if (!active) return; + if (active.highlightClass && active.addedClass) { + active.target.classList.remove(active.highlightClass); + } + for (const style of active.inlineStyles ?? []) { + if (style.value) { + active.target.style.setProperty(style.name, style.value, style.priority); + } else { + active.target.style.removeProperty(style.name); + } + } + activePageCitationHighlights.delete(root); +} + +/** + * 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 { + clearPageCitationHighlight(root); + 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) { + const addedClass = !target.classList.contains(options.highlightClass); + target.classList.add(options.highlightClass); + activePageCitationHighlights.set(root, { + target, + highlightClass: options.highlightClass, + addedClass, + }); + } else if (options.highlight !== false) { + const names = ["outline", "outline-offset", "background-color"]; + const inlineStyles = names.map((name) => ({ + name, + value: target.style.getPropertyValue(name), + priority: target.style.getPropertyPriority(name), + })); + 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"); + activePageCitationHighlights.set(root, { target, inlineStyles }); + } + target.scrollIntoView({ + behavior: options.behavior ?? "smooth", + block: options.block ?? "center", + }); + return { + navigated: true, + target, + pageNumber: fragment.pageNumber, + fragmentId: fragment.fragmentId, + }; } /** @@ -138,12 +314,17 @@ 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") // Maximum percentage of content height that footnotes can occupy // This allows footnotes to expand upward into body content space when needed const MAX_FOOTNOTE_AREA_RATIO = 0.6; // 60% of content height +// Hidden measurement and final absolutely-positioned note bands differ by sub-pixel border/margin +// rounding in Chromium. Reserve a small physical-unit guard so the last baseline stays visible. +const FOOTNOTE_MEASUREMENT_GUARD_PT = 2; // Minimum body content height per page (to avoid pages with only footnotes) const MIN_BODY_CONTENT_HEIGHT = 72; // 1 inch minimum body content @@ -163,6 +344,8 @@ export type FootnoteRegistry = Map; interface FootnoteContinuation { /** The footnote ID being continued */ footnoteId: string; + /** Canonical fn:* definition identity from the registry wrapper. */ + sourceAnchorId?: string; /** Remaining paragraphs/elements that didn't fit */ remainingElements: HTMLElement[]; } @@ -195,11 +378,15 @@ 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 commentMarginRegistry: Map; 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,8 +421,10 @@ 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(); + this.commentMarginRegistry = new Map(); } /** @@ -253,6 +442,10 @@ export class PaginationEngine { // Parse the footnote registry if present this.footnoteRegistry = this.parseFootnoteRegistry(); + // Parse the margin-comment registry if present. Its entries are cloned into + // the side substrate of pages that contain the corresponding range marker. + this.commentMarginRegistry = this.parseCommentMarginRegistry(); + // Find all section containers const sections = this.stagingElement.querySelectorAll( "[data-section-index]" @@ -264,6 +457,37 @@ 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(); + const referencedCommentIds = 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 reference of Array.from(section.querySelectorAll("[data-comment-id]"))) { + if (reference.closest( + "#pagination-comment-margin-registry, #pagination-footnote-registry, #pagination-hf-registry", + )) continue; + const id = reference.dataset.commentId; + if (id) referencedCommentIds.add(id); + } + } + for (const id of referencedFootnoteIds) { + const source = this.footnoteRegistry.get(id); + if (source) this.collectExpectedSourceAnchors(source, this.expectedPageMapAnchorIds); + } + for (const id of referencedCommentIds) { + const source = this.commentMarginRegistry.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 +557,310 @@ 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.transferVisibleFragmentTargets(); + 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); + if (requiredAnchorIds.size === 0) { + throw new Error("cannot publish an available PageMap without canonical source inventory"); + } + 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)) { + // Preserve the source-side exclusion contract on presentation clones as well. This + // covers the node itself and any excluded/hidden/aria-hidden ancestor within the page. + if (this.isDeliberatelyUnrenderedSource(element, page.element)) continue; + 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 visibleRect = this.intersectWithClippingAncestors(element, page.element, pageRect, rect); + const left = visibleRect.left; + const top = visibleRect.top; + const right = visibleRect.right; + const bottom = visibleRect.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, + }; + } + + /** + * Intersect an element with every ancestor that establishes an overflow clip before the page + * root. getBoundingClientRect() reports layout outside those clips, which is not rendered and + * therefore must not satisfy PageMap completeness or inflate portable geometry. + */ + private intersectWithClippingAncestors( + element: HTMLElement, + page: HTMLElement, + pageRect: DOMRect, + rect: DOMRect, + ): { left: number; top: number; right: number; bottom: number } { + let left = Math.max(rect.left, pageRect.left); + let top = Math.max(rect.top, pageRect.top); + let right = Math.min(rect.right, pageRect.right); + let bottom = Math.min(rect.bottom, pageRect.bottom); + const clips = (value: string) => + value === "hidden" || value === "clip" || value === "scroll" || value === "auto"; + + for (let ancestor = element.parentElement; + ancestor && ancestor !== page; + ancestor = ancestor.parentElement) { + const style = window.getComputedStyle(ancestor); + const clipsX = clips(style.overflowX); + const clipsY = clips(style.overflowY); + if (!clipsX && !clipsY) continue; + const ancestorRect = ancestor.getBoundingClientRect(); + if (clipsX) { + left = Math.max(left, ancestorRect.left); + right = Math.min(right, ancestorRect.right); + } + if (clipsY) { + top = Math.max(top, ancestorRect.top); + bottom = Math.min(bottom, ancestorRect.bottom); + } + } + return { left, top, right, bottom }; + } + + 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, #pagination-comment-margin-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); + } + } + } + + /** + * Page flow clones source blocks while the hidden staging tree stays in the document. Any HTML + * fragment target copied into a visible page would therefore resolve to its earlier hidden + * source. Transfer target ownership to the page presentation after flow is complete; registry + * and wrapper IDs that have no visible counterpart remain available to pagination internals. + */ + private transferVisibleFragmentTargets(): void { + const visibleIds = new Set(Array.from( + this.containerElement.querySelectorAll("[id]"), + ).map((element) => element.id).filter(Boolean)); + if (visibleIds.size === 0) return; + for (const source of Array.from( + this.stagingElement.querySelectorAll("[id]"), + )) { + if (visibleIds.has(source.id)) source.removeAttribute("id"); + } } /** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */ @@ -415,6 +942,18 @@ export class PaginationEngine { continue; } + // Converter-shaped endnotes are a safe nested block structure whose outer + // section/list wrappers must not make the complete endnote collection one + // indivisible page block. Flatten only the exact shape we understand; any + // richer author HTML retains the conservative whole-block fallback below. + if (child.matches("section.endnotes")) { + const endnoteBlocks = this.measureSafeEndnoteBlocks(child, dims); + if (endnoteBlocks) { + blocks.push(...endnoteBlocks); + continue; + } + } + // Measure height and margins separately for proper margin collapsing calculation // getBoundingClientRect() returns content+padding+border, not margins const rect = child.getBoundingClientRect(); @@ -445,6 +984,115 @@ export class PaginationEngine { return blocks; } + /** + * Flatten the converter's `section.endnotes > ol > li > p` presentation into + * ordinary paragraph blocks. This preserves paragraph formatting and canonical + * p:en/en:en identities while allowing the existing paragraph fragmenter to + * split a long endnote across page boundaries. + */ + private measureSafeEndnoteBlocks( + section: HTMLElement, + dims: PageDimensions, + ): MeasuredBlock[] | null { + const sectionChildren = Array.from(section.children) as HTMLElement[]; + const list = sectionChildren.find((child) => child.tagName === "OL"); + if ( + !list + || sectionChildren.some((child) => child.tagName !== "HR" && child !== list) + || Array.from(list.children).some((child) => child.tagName !== "LI") + ) { + return null; + } + + const items = Array.from(list.children) as HTMLElement[]; + if (items.length === 0 || items.some((item) => + item.children.length === 0 + || Array.from(item.children).some((child) => child.tagName !== "P") + )) { + return null; + } + + const blocks: MeasuredBlock[] = []; + const sectionStyle = window.getComputedStyle(section); + for (const rule of sectionChildren.filter((child) => child.tagName === "HR")) { + const clonedRule = rule.cloneNode(true) as HTMLElement; + if (blocks.length === 0) clonedRule.style.marginTop = sectionStyle.marginTop; + blocks.push(this.measureElement(clonedRule, dims)); + } + + const listStyle = window.getComputedStyle(list); + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + const item = items[itemIndex]; + const ownerAnchorId = item.dataset.sourceAnchorId; + const paragraphs = Array.from(item.children) as HTMLElement[]; + for (let paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex++) { + const paragraph = paragraphs[paragraphIndex]; + // The flattened clone no longer has the section/ol/li ancestors that supplied the + // source's computed layout. Validate while the real paragraph is still attached; the + // marker below records that completed check for canFragmentParagraph(), whose detached + // clone cannot obtain meaningful computed styles. A richer custom endnote falls back to + // the established indivisible section path instead of being range-split incorrectly. + if (!this.hasRangeFragmentSafeLayout(paragraph)) { + return null; + } + + const clone = paragraph.cloneNode(true) as HTMLElement; + clone.dataset.paginationSafeEndnote = "true"; + clone.style.fontSize ||= sectionStyle.fontSize; + clone.style.lineHeight ||= sectionStyle.lineHeight; + clone.style.paddingLeft ||= listStyle.paddingLeft; + + // Older/custom producers may put the endnote identity only on the li. + // Mirror it into the visible paragraph without displacing the paragraph's + // own identity, matching current converter output. + if (ownerAnchorId && !Array.from( + clone.querySelectorAll("[data-source-anchor-id]"), + ).some((node) => node.dataset.sourceAnchorId === ownerAnchorId)) { + const owner = document.createElement("span"); + owner.dataset.sourceAnchorId = ownerAnchorId; + while (clone.firstChild) owner.appendChild(clone.firstChild); + clone.appendChild(owner); + } + + if (paragraphIndex === 0) { + // Flattening must preserve both ends of the converter's endnote link and the list's + // numbering format. The outer id survives only on the leading range fragment; normal + // continuation cleanup removes it from every later fragment. + const itemId = item.id; + if (itemId) { + if (clone.id && clone.id !== itemId) return null; + clone.id = itemId; + } + const value = parseInt(item.getAttribute("value") || String(itemIndex + 1), 10); + const marker = this.formatOrderedListMarker( + Number.isFinite(value) ? value : itemIndex + 1, + listStyle.listStyleType, + ); + clone.insertBefore(document.createTextNode(`${marker}. `), clone.firstChild); + } + blocks.push(this.measureElement(clone, dims)); + } + } + + return blocks; + } + + /** Render the CSS ordered-list formats emitted by the converter after an endnote is flattened. */ + private formatOrderedListMarker(value: number, listStyleType: string): string { + const format = (() => { + switch (listStyleType) { + case "lower-roman": return "lowerRoman"; + case "upper-roman": return "upperRoman"; + case "lower-alpha": + case "lower-latin": return "lowerLetter"; + case "upper-alpha": + case "upper-latin": return "upperLetter"; + default: return "decimal"; + } + })(); + return formatPageNumber(value, format); + } + /** * Flows a multi-column (`w:cols`) section's children into CSS-multicol container * blocks. Word lays such a section out as N columns inside the same body extent; @@ -968,6 +1616,16 @@ export class PaginationEngine { return false; } + const isValidatedEndnote = paragraph.dataset.paginationSafeEndnote === "true"; + return isValidatedEndnote || this.hasRangeFragmentSafeLayout(paragraph); + } + + /** + * A range clone preserves nested inline formatting exactly. Anything that establishes its own + * box/layout context is deferred until a future fragmenter can model it accurately. Callers + * must invoke this while the paragraph is attached to the styled document. + */ + private hasRangeFragmentSafeLayout(paragraph: HTMLElement): boolean { const paragraphStyle = window.getComputedStyle(paragraph); if ( paragraphStyle.display !== "block" || @@ -984,21 +1642,17 @@ export class PaginationEngine { return false; } - // A range clone preserves nested inline formatting exactly. Anything that - // establishes its own box/layout context is intentionally deferred until a - // future fragmenter can model it accurately. for (const descendant of Array.from(paragraph.querySelectorAll("*"))) { const style = window.getComputedStyle(descendant); if ( style.display !== "inline" || style.position !== "static" || style.float !== "none" || - style.whiteSpace !== "normal" + (style.whiteSpace !== "normal" && style.whiteSpace !== "pre-wrap") ) { return false; } } - return true; } @@ -1215,6 +1869,23 @@ export class PaginationEngine { return registry; } + /** Parses the hidden source notes used to render paginated margin comments. */ + private parseCommentMarginRegistry(): Map { + const registry = new Map(); + const registryEl = this.stagingElement.querySelector( + "#pagination-comment-margin-registry", + ); + if (!registryEl) return registry; + + for (const entry of Array.from( + registryEl.querySelectorAll("[data-comment-id]"), + )) { + const commentId = entry.dataset.commentId; + if (commentId) registry.set(commentId, entry.cloneNode(true) as HTMLElement); + } + return registry; + } + /** * Extracts footnote reference IDs from an element. */ @@ -1266,6 +1937,9 @@ export class PaginationEngine { if (hasContinuation) { const contWrapper = document.createElement("div"); contWrapper.className = "footnote-continuation"; + if (continuation!.sourceAnchorId) { + contWrapper.dataset.sourceAnchorId = continuation!.sourceAnchorId; + } for (const el of continuation!.remainingElements) { contWrapper.appendChild(el.cloneNode(true)); } @@ -1328,6 +2002,47 @@ export class PaginationEngine { return heightPt; } + /** + * Partition a continuation at complete child boundaries for one page's note band. + * Always advances by at least one element so an indivisible oversized paragraph + * follows the established clipped fallback without trapping pagination in a loop. + */ + private splitContinuationForPage( + continuation: FootnoteContinuation, + availableHeightPt: number, + contentWidth: number, + ): { current: FootnoteContinuation; overflow: FootnoteContinuation | null } { + const fitting: HTMLElement[] = []; + let fittingHeight = 0; + + for (const element of continuation.remainingElements) { + const candidate: FootnoteContinuation = { + footnoteId: continuation.footnoteId, + sourceAnchorId: continuation.sourceAnchorId, + remainingElements: [...fitting, element], + }; + const candidateHeight = this.measureContinuationHeight(candidate, contentWidth); + if (fitting.length > 0 && candidateHeight > availableHeightPt) break; + fitting.push(element); + fittingHeight = candidateHeight; + if (fittingHeight >= availableHeightPt) break; + } + + const remaining = continuation.remainingElements.slice(fitting.length); + return { + current: { + footnoteId: continuation.footnoteId, + sourceAnchorId: continuation.sourceAnchorId, + remainingElements: fitting, + }, + overflow: remaining.length > 0 ? { + footnoteId: continuation.footnoteId, + sourceAnchorId: continuation.sourceAnchorId, + remainingElements: remaining, + } : null, + }; + } + /** * Splits a footnote element into parts that fit within the available height. * Returns the elements that fit and the elements that need to continue. @@ -1488,6 +2203,9 @@ export class PaginationEngine { if (hasContinuation) { const contWrapper = document.createElement("div"); contWrapper.className = "footnote-continuation"; + if (continuation!.sourceAnchorId) { + contWrapper.dataset.sourceAnchorId = continuation!.sourceAnchorId; + } for (const el of continuation!.remainingElements) { contWrapper.appendChild(el.cloneNode(true)); } @@ -1505,6 +2223,9 @@ export class PaginationEngine { const partialDiv = document.createElement("div"); partialDiv.className = "footnote-item"; partialDiv.dataset.footnoteId = id; + if (footnote.dataset.sourceAnchorId) { + partialDiv.dataset.sourceAnchorId = footnote.dataset.sourceAnchorId; + } // Add footnote number const numberSpan = footnote.querySelector(".footnote-number"); @@ -1729,7 +2450,85 @@ export class PaginationEngine { const finishPage = () => { const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0; - if (currentContent.length === 0 && !hasCurrentContinuation) return; + if (currentContent.length === 0 && currentFootnoteIds.length === 0 + && !hasCurrentContinuation) return; + + let pageContinuation = currentContinuation; + const pageBands = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber); + const maxFootnoteHeight = pageBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO; + if (currentContinuation && currentContinuation.remainingElements.length > 0) { + const partition = this.splitContinuationForPage( + currentContinuation, + maxFootnoteHeight, + dims.contentWidth, + ); + pageContinuation = partition.current; + if (partition.overflow) { + // A carried tail occupies the next page before any newly introduced note. + // The normal flow never starts another splittable note while an oversized + // continuation already consumes the note budget, so this assignment does + // not discard an independent continuation. + nextPageContinuation = partition.overflow; + } + currentFootnoteHeight = this.measureContinuationHeight( + pageContinuation, + dims.contentWidth, + ); + } + + // A final body page can seed the next page with several whole notes. Partition that queue + // before materializing a note-only page: addPageFootnotes deliberately clips its band, so + // placing the entire queue in one page would keep the DOM nodes while making later notes + // invisible (and therefore absent from a geometry-clipped PageMap). + if (currentContent.length === 0 && currentFootnoteIds.length > 0 + && currentPartialFootnotes.length === 0) { + const fittingIds: string[] = []; + for (let index = 0; index < currentFootnoteIds.length; index++) { + const footnoteId = currentFootnoteIds[index]; + const candidateIds = [...fittingIds, footnoteId]; + const candidateHeight = this.measureFootnotesHeight( + candidateIds, dims.contentWidth, pageContinuation); + const guardedCandidateHeight = candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT; + if (guardedCandidateHeight <= maxFootnoteHeight) { + fittingIds.push(footnoteId); + currentFootnoteHeight = guardedCandidateHeight; + continue; + } + + const hasPageContinuation = + (pageContinuation?.remainingElements.length ?? 0) > 0; + if (fittingIds.length === 0 && !hasPageContinuation) { + // One note alone is taller than the note band. Split at the same safe paragraph + // boundaries used during body flow; if it is indivisible, preserve the established + // visible clipped fallback while still advancing the queue. + const source = this.footnoteRegistry.get(footnoteId); + const split = source + ? this.splitFootnoteToFit( + source, + maxFootnoteHeight - FOOTNOTE_MEASUREMENT_GUARD_PT, + dims.contentWidth, + ) + : null; + fittingIds.push(footnoteId); + if (source && split && split.fits.length > 0 && split.overflow.length > 0) { + currentPartialFootnotes.push({ footnoteId, fittingElements: split.fits }); + nextPageContinuation = { + footnoteId, + sourceAnchorId: source.dataset.sourceAnchorId, + remainingElements: split.overflow, + }; + currentFootnoteHeight = maxFootnoteHeight; + } else { + currentFootnoteHeight = guardedCandidateHeight; + } + deferredFootnoteIds.push(...currentFootnoteIds.slice(index + 1)); + } else { + deferredFootnoteIds.push(...currentFootnoteIds.slice(index)); + } + break; + } + currentFootnoteIds = fittingIds; + } const page = this.createPage( dims, @@ -1739,7 +2538,7 @@ export class PaginationEngine { pageInSection, currentFootnoteIds, currentFootnoteHeight, - currentContinuation, + pageContinuation, currentPartialFootnotes.length > 0 ? currentPartialFootnotes : undefined ); pages.push(page); @@ -1999,6 +2798,7 @@ export class PaginationEngine { if (overflow.length > 0) { nextPageContinuation = { footnoteId, + sourceAnchorId: footnote.dataset.sourceAnchorId, remainingElements: overflow }; } @@ -2102,6 +2902,16 @@ export class PaginationEngine { // Finish last page finishPage(); + // A split created while finishing the final body page still needs a page substrate. + // Drain all remaining note paragraphs into footnote-only continuation pages. + while ( + currentFootnoteIds.length > 0 + || deferredFootnoteIds.length > 0 + || (currentContinuation?.remainingElements.length ?? 0) > 0 + ) { + finishPage(); + } + // Store any remaining continuation for next section this.pendingFootnoteContinuation = nextPageContinuation; @@ -2131,6 +2941,20 @@ export class PaginationEngine { } } + /** A repeated margin note is presentation, not a second bookmark/link target. */ + private makeClonedMarginCommentInert(root: HTMLElement): void { + this.makeClonedStoryInert(root); + const nodes = [root, ...Array.from(root.querySelectorAll("*"))]; + for (const element of nodes) { + element.removeAttribute("id"); + if (element instanceof HTMLAnchorElement && element.getAttribute("href")?.startsWith("#")) { + element.removeAttribute("href"); + element.setAttribute("aria-disabled", "true"); + element.tabIndex = -1; + } + } + } + /** * Resolves floating DrawingML objects after their anchor paragraphs have landed on a page. * @@ -2440,6 +3264,39 @@ export class PaginationEngine { pageBox.appendChild(contentArea); + // Materialize margin comments in a page-owned side column. The body only carries range + // markers; definition and comment-paragraph identities live on these selected registry + // clones, so PageMap geometry describes the actual visible margin presentation. + const pageCommentIds: string[] = []; + for (const marker of Array.from( + contentArea.querySelectorAll("[data-comment-id]"), + )) { + const id = marker.dataset.commentId; + if (id && this.commentMarginRegistry.has(id) && !pageCommentIds.includes(id)) { + pageCommentIds.push(id); + } + } + if (pageCommentIds.length > 0) { + const marginColumn = document.createElement("aside"); + marginColumn.className = `${this.cssPrefix}comment-margin`; + marginColumn.style.position = "absolute"; + marginColumn.style.top = `${contentAreaTop}pt`; + marginColumn.style.left = `${dims.marginLeft + dims.contentWidth + 3}pt`; + marginColumn.style.width = `${Math.max(12, dims.marginRight - 6)}pt`; + marginColumn.style.maxHeight = `${contentAreaHeight}pt`; + marginColumn.style.overflow = "hidden"; + marginColumn.style.boxSizing = "border-box"; + for (const id of pageCommentIds) { + const source = this.commentMarginRegistry.get(id); + if (source) { + const clone = source.cloneNode(true) as HTMLElement; + this.makeClonedMarginCommentInert(clone); + marginColumn.appendChild(clone); + } + } + pageBox.appendChild(marginColumn); + } + // Add footnotes if any references appear on this page (or continuation from previous) const hasContinuation = continuation && continuation.remainingElements.length > 0; if (footnoteIds.length > 0 || hasContinuation) { diff --git a/npm/src/react.ts b/npm/src/react.ts index faee741a..e5456df2 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,12 @@ import { } from "./types.js"; import { PaginationEngine, + clearPageCitationHighlight, + navigateToPageCitation, type PaginationOptions, type PaginationResult, + type PageMap, + type PageCitationNavigation, } from "./pagination.js"; export type { @@ -62,6 +67,9 @@ export type { Revision, PaginationOptions, PaginationResult, + PageMap, + PageCitationNavigation, + PageCitation, Annotation, AddAnnotationRequest, AddAnnotationResponse, @@ -601,6 +609,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 +619,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 +681,7 @@ export function usePagination( pageGap = 20, cssPrefix = "page-", fragmentParagraphs = true, + layoutToken, } = options; const paginate = useCallback(() => { @@ -706,6 +719,7 @@ export function usePagination( pageGap, cssPrefix, fragmentParagraphs, + layoutToken, }; const engine = new PaginationEngine(staging, pageContainer, engineOptions); const paginationResult = engine.paginate(); @@ -715,7 +729,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 +792,12 @@ export function PaginatedDocument({ showPageNumbers = true, pageGap = 20, fragmentParagraphs = true, + layoutToken, backgroundColor = "#525659", cssPrefix = "page-", onPaginationComplete, onPageVisible, + citation, className, style, }: PaginatedDocumentProps): ReactElement { @@ -785,7 +811,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 +858,19 @@ export function PaginatedDocument({ return () => observer.disconnect(); }, [result, cssPrefix, onPageVisible]); + useEffect(() => { + const root = containerRef.current; + if (!root) return; + if (!result || !citation) { + clearPageCitationHighlight(root); + return; + } + navigateToPageCitation(root, citation, { + highlight: true, + }); + return () => clearPageCitationHighlight(root); + }, [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..f7ec62e4 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,63 @@ 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 PageCitationPage { + pageNumber: number; + pageInSection: number; + width: number; + height: number; + sectionIndex?: number; + pageName: string; +} + +export interface PageCitation { + anchorId: string; + availability: "available" | "unavailable"; + unavailableReason?: PageCitationUnavailableReason; + documentVersion: number; + rendererFingerprint: string; + pages: PageCitationPage[]; + 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 +1862,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 +1896,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 +2095,8 @@ export interface GrepOptions { * `contextChars`. */ boundary?: number; + /** Attach citations only if this exact registered layout is still valid. */ + citation?: PageCitationRequest; } /** @@ -2039,6 +2123,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 +2140,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 +2991,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/docx-footnote-fixture.ts b/npm/tests/docx-footnote-fixture.ts index 8e887c3f..c3f04ed5 100644 --- a/npm/tests/docx-footnote-fixture.ts +++ b/npm/tests/docx-footnote-fixture.ts @@ -29,10 +29,15 @@ function bodyParagraph(index: number, noteId: number | null): string { `Body line ${index + 1}${citation}`; } -function footnote(id: number): string { - return `` + - `` + - ` Footnote ${id} text.`; +function footnote(id: number, paragraphCount: number): string { + const paragraphs = Array.from({ length: paragraphCount }, (_, index) => + `` + + (index === 0 + ? `` + : '') + + ` Footnote ${id} paragraph ${index + 1} text.`) + .join(''); + return `${paragraphs}`; } /** @@ -40,7 +45,11 @@ function footnote(id: number): string { * @param bodyLines total body paragraphs — few enough to keep everything on one page, so the * note area's position is determined purely by the page geometry, not by flow pressure. */ -export function generateFootnoteDocx(noteCount = 1, bodyLines = 5): Uint8Array { +export function generateFootnoteDocx( + noteCount = 1, + bodyLines = 5, + paragraphsPerNote = 1, +): Uint8Array { const noteIds = Array.from({ length: noteCount }, (_, i) => i + 1); const body = Array.from({ length: bodyLines }, (_, i) => bodyParagraph(i, i < noteCount ? i + 1 : null)).join(''); @@ -97,7 +106,7 @@ export function generateFootnoteDocx(noteCount = 1, bodyLines = 5): Uint8Array { - ${noteIds.map(footnote).join('\n ')} + ${noteIds.map((id) => footnote(id, paragraphsPerNote)).join('\n ')} `), }, { diff --git a/npm/tests/docx-page-map-fixture.ts b/npm/tests/docx-page-map-fixture.ts new file mode 100644 index 00000000..d18a192e --- /dev/null +++ b/npm/tests/docx-page-map-fixture.ts @@ -0,0 +1,134 @@ +import { storedZip, xml, R_NS, W_NS } from './docx-zip.js'; + +const CONTENT_TYPES = (partName: string, contentType: string) => xml(` + + + + + + +`); + +const PACKAGE_RELS = xml(` + + +`); + +const PAGE = ` + + + + `; + +const STYLES = xml(` + + + + + + + + + + + + +`); + +/** A two-paragraph native comment whose range lives in a table cell. */ +export function generateTableCommentDocx(collapsed = false): Uint8Array { + const commentedBody = collapsed + ? `` + : `Cell comment target + `; + return storedZip([ + { + name: '[Content_Types].xml', + data: CONTENT_TYPES( + '/word/comments.xml', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml', + ), + }, + { name: '_rels/.rels', data: PACKAGE_RELS }, + { + name: 'word/_rels/document.xml.rels', + data: xml(` + + + +`), + }, + { name: 'word/styles.xml', data: STYLES }, + { + name: 'word/comments.xml', + data: xml(` + + + + First comment paragraph. + + Second comment paragraph. + +`), + }, + { + name: 'word/document.xml', + data: xml(` + + + + ${commentedBody} + + + Following body paragraph. + ${PAGE} +`), + }, + ]); +} + +/** A real native endnote with one deliberately oversized text paragraph. */ +export function generateLongEndnoteDocx(wordCount = 1200): Uint8Array { + const words = Array.from({ length: wordCount }, (_, index) => `endnote${index}`).join(' '); + return storedZip([ + { + name: '[Content_Types].xml', + data: CONTENT_TYPES( + '/word/endnotes.xml', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml', + ), + }, + { name: '_rels/.rels', data: PACKAGE_RELS }, + { + name: 'word/_rels/document.xml.rels', + data: xml(` + + + +`), + }, + { name: 'word/styles.xml', data: STYLES }, + { + name: 'word/endnotes.xml', + data: xml(` + + + + + + ${words} +`), + }, + { + name: 'word/document.xml', + data: xml(` + + Body with a long endnote + + + ${PAGE} +`), + }, + ]); +} diff --git a/npm/tests/page-map-real-converter.spec.ts b/npm/tests/page-map-real-converter.spec.ts new file mode 100644 index 00000000..4508a594 --- /dev/null +++ b/npm/tests/page-map-real-converter.spec.ts @@ -0,0 +1,233 @@ +import { expect, Page, test } from '@playwright/test'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { generateFootnoteDocx } from './docx-footnote-fixture.js'; +import { generateLongEndnoteDocx, generateTableCommentDocx } from './docx-page-map-fixture.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function readyPage(page: Page): Promise { + await page.goto('/test-harness.html'); + await page.waitForFunction(() => (window as any).DocxodusReady === true, { timeout: 30000 }); + await page.addScriptTag({ path: path.join(__dirname, '../dist/pagination.bundle.js') }); +} + +interface RealPaginationResult { + htmlHasBareAnchors: boolean; + htmlCanonicalCount: number; + pages: number; + fragments: Array<{ + anchorId: string; + pageNumber: number; + story: string; + inTableCell: boolean; + geometry: { x: number; y: number; width: number; height: number }; + }>; + registration: { success: boolean; error?: string; message?: string }; + commentSectionInStaging: boolean; + marginRegistryInStaging: boolean; + marginColumns: number; + duplicatePageCommentIds: number; + activeMarginBackrefs: number; + endnoteReferenceCount: number; + endnoteTargetCount: number; + endnoteReferenceResolves: boolean; + endnoteLinkTargetsUnique: boolean; + endnoteMarkerText: string | null; +} + +async function convertPaginateAndRegister( + page: Page, + docx: Uint8Array, + commentMode: number, + renderNotes: boolean, + fingerprint: string, +): Promise { + return page.evaluate(({ bytes, commentMode, renderNotes, fingerprint }) => { + const D = (window as any).Docxodus; + const bin = new Uint8Array(bytes); + const html: string = D.DocumentConverter.ConvertDocxToHtmlComplete( + bin, 'Document', 'docx-', false, '', commentMode, 'comment-', + /* paginationMode */ 1, 1, 'page-', false, 0, 'annot-', + renderNotes, false, false, false, false, false, null, + /* stampAnchors */ false, + ); + if (html.startsWith('{')) throw new Error(`conversion failed: ${html.slice(0, 300)}`); + + const host = document.createElement('div'); + host.className = 'real-page-map-host'; + host.innerHTML = html; + document.body.appendChild(host); + const staging = host.querySelector('#pagination-staging')!; + const container = host.querySelector('#pagination-container')!; + const engine = new (window as any).DocxodusPagination.PaginationEngine(staging, container, { + showPageNumbers: false, + fragmentParagraphs: true, + layoutToken: { documentVersion: 0, rendererFingerprint: fingerprint }, + }); + const pagination = engine.paginate(); + + const bridge = D.DocxSessionBridge; + const handle = bridge.OpenSession(bin, ''); + let registration: { success: boolean; error?: string; message?: string }; + try { + registration = JSON.parse(bridge.RegisterPageMap( + handle, JSON.stringify(pagination.pageMap), fingerprint, + )); + } finally { + bridge.CloseSession(handle); + } + + return { + htmlHasBareAnchors: /\bdata-anchor=/.test(html), + htmlCanonicalCount: (html.match(/\bdata-source-anchor-id=/g) || []).length, + pages: pagination.totalPages, + fragments: pagination.pageMap.fragments, + registration, + commentSectionInStaging: Array.from(staging.querySelectorAll( + '[data-section-index] aside', + )).some((node) => node.className.includes('comments-section')), + marginRegistryInStaging: staging.querySelector( + '#pagination-comment-margin-registry', + ) !== null, + marginColumns: container.querySelectorAll('.page-comment-margin').length, + duplicatePageCommentIds: container.querySelectorAll('.page-comment-margin [id]').length, + activeMarginBackrefs: container.querySelectorAll( + '.page-comment-margin a[href^="#"]', + ).length, + endnoteReferenceCount: container.querySelectorAll( + 'a[href^="#en-"]:not([href^="#en-ref-"])', + ).length, + endnoteReferenceResolves: Array.from(container.querySelectorAll( + 'a[href^="#en-"]', + )).every((link) => { + const target = document.querySelector(link.getAttribute('href')!); + return target !== null && container.contains(target); + }), + endnoteLinkTargetsUnique: Array.from(container.querySelectorAll( + 'a[href^="#en-"]', + )).every((link) => document.querySelectorAll( + `[id="${CSS.escape(link.getAttribute('href')!.slice(1))}"]`, + ).length === 1), + endnoteTargetCount: document.querySelectorAll('#en-1').length, + endnoteMarkerText: container.querySelector('#en-1')?.textContent?.trim() + ?? null, + }; + }, { + bytes: Array.from(docx), + commentMode, + renderNotes, + fingerprint, + }); +} + +test.describe('Real converter PageMap pipeline', () => { + test.beforeEach(async ({ page }) => readyPage(page)); + + for (const mode of [ + { name: 'endnote-style', value: 0 }, + { name: 'inline', value: 1 }, + { name: 'margin', value: 2 }, + ]) { + test(`default no-stamp ${mode.name} comments paginate and register`, async ({ page }) => { + const result = await convertPaginateAndRegister( + page, + generateTableCommentDocx(), + mode.value, + false, + `real-comment-${mode.name}-v1`, + ); + const comments = result.fragments.filter((fragment) => fragment.story === 'comment'); + expect(result.htmlHasBareAnchors).toBe(false); + expect(result.htmlCanonicalCount).toBeGreaterThan(0); + expect(result.registration.success, JSON.stringify(result.registration)).toBe(true); + expect(comments.length).toBeGreaterThanOrEqual(3); // cmt + two p:cmt definitions + + if (mode.value === 0) { + expect(result.commentSectionInStaging).toBe(true); + expect(comments.every((fragment) => !fragment.inTableCell)).toBe(true); + } else if (mode.value === 1) { + expect(comments.every((fragment) => fragment.inTableCell)).toBe(true); + } else { + expect(result.marginRegistryInStaging).toBe(true); + expect(result.marginColumns).toBeGreaterThan(0); + expect(comments.every((fragment) => !fragment.inTableCell)).toBe(true); + expect(result.duplicatePageCommentIds).toBe(0); + expect(result.activeMarginBackrefs).toBe(0); + } + }); + } + + for (const mode of [ + { name: 'inline', value: 1 }, + { name: 'margin', value: 2 }, + ]) { + test(`collapsed ${mode.name} comment maps its visible reference`, async ({ page }) => { + const result = await convertPaginateAndRegister( + page, + generateTableCommentDocx(true), + mode.value, + false, + `collapsed-comment-${mode.name}-v1`, + ); + const comments = result.fragments.filter((fragment) => fragment.story === 'comment'); + expect(result.registration.success, JSON.stringify(result.registration)).toBe(true); + expect(comments.length).toBeGreaterThanOrEqual(3); + if (mode.value === 1) { + expect(comments.every((fragment) => fragment.inTableCell)).toBe(true); + } else { + expect(result.marginColumns).toBeGreaterThan(0); + expect(comments.every((fragment) => !fragment.inTableCell)).toBe(true); + } + }); + } + + test('split real footnote keeps its fn definition identity on every continuation page', async ({ page }) => { + const result = await convertPaginateAndRegister( + page, + generateFootnoteDocx(1, 1, 90), + -1, + true, + 'real-footnote-continuation-v1', + ); + const definitions = result.fragments.filter((fragment) => + fragment.story === 'footnote' && fragment.anchorId.startsWith('fn:fn:')); + const paragraphDefinitions = result.fragments.filter((fragment) => + fragment.story === 'footnote' && fragment.anchorId.startsWith('p:fn:')); + expect(result.registration.success, JSON.stringify(result.registration)).toBe(true); + expect(new Set(definitions.map((fragment) => fragment.pageNumber)).size).toBeGreaterThan(1); + expect(new Set(paragraphDefinitions.map((fragment) => fragment.pageNumber)).size).toBeGreaterThan(1); + expect(result.fragments.every((fragment) => + fragment.geometry.x >= 0 + && fragment.geometry.y >= 0 + && fragment.geometry.width > 0 + && fragment.geometry.height > 0)).toBe(true); + }); + + test('one long real endnote paragraph fragments across final flow pages', async ({ page }) => { + const result = await convertPaginateAndRegister( + page, + generateLongEndnoteDocx(), + -1, + true, + 'real-long-endnote-v1', + ); + const definitions = result.fragments.filter((fragment) => + fragment.story === 'endnote' && fragment.anchorId.startsWith('en:en:')); + const paragraphs = result.fragments.filter((fragment) => + fragment.story === 'endnote' && fragment.anchorId.startsWith('p:en:')); + expect(result.registration.success, JSON.stringify(result.registration)).toBe(true); + expect(result.pages).toBeGreaterThan(1); + expect( + new Set(definitions.map((fragment) => fragment.pageNumber)).size, + JSON.stringify({ pages: result.pages, definitions, paragraphs }), + ).toBeGreaterThan(1); + expect(new Set(paragraphs.map((fragment) => fragment.pageNumber)).size).toBeGreaterThan(1); + expect(result.endnoteReferenceCount).toBeGreaterThan(0); + expect(result.endnoteTargetCount).toBe(1); + expect(result.endnoteReferenceResolves).toBe(true); + expect(result.endnoteLinkTargetsUnique).toBe(true); + expect(result.endnoteMarkerText).toMatch(/^i\.\s/); + }); +}); diff --git a/npm/tests/page-map.spec.ts b/npm/tests/page-map.spec.ts new file mode 100644 index 00000000..5ad509ec --- /dev/null +++ b/npm/tests/page-map.spec.ts @@ -0,0 +1,512 @@ +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'); + }); + + test('excludes explicit, hidden, and aria-hidden source subtrees from page clones and the map', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + const result = await page.evaluate((html) => { + const pagination = (window as any).DocxodusPagination.paginateHtml(html, 'viewer', { + showPageNumbers: false, + layoutToken: { documentVersion: 0, rendererFingerprint: 'excluded-v1' }, + }); + return { + anchors: pagination.pageMap.fragments.map((fragment: any) => fragment.anchorId), + pageSources: Array.from(document.querySelectorAll( + '#pagination-container [data-source-anchor-id]', + )).map((node) => node.dataset.sourceAnchorId), + }; + }, shell(` +
+

visible

+

explicit

+ + +
`)); + + expect(result.anchors).toEqual(['p:body:visible']); + expect(result.pageSources).toContain('p:body:visible'); + expect(result.pageSources).toContain('p:body:explicit'); + expect(result.pageSources).toContain('p:body:hidden'); + expect(result.pageSources).toContain('p:body:aria'); + }); + + test('clips fragment geometry to the visible overflow band, not only the page box', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + const geometry = await page.evaluate((html) => { + const result = (window as any).DocxodusPagination.paginateHtml(html, 'viewer', { + showPageNumbers: false, + fragmentParagraphs: false, + layoutToken: { documentVersion: 0, rendererFingerprint: 'clip-band-v1' }, + }); + return result.pageMap.fragments.find((fragment: any) => + fragment.anchorId === 'p:body:clipped').geometry; + }, shell(` +
+

partly visible overflow

+
`)); + expect(geometry.y).toBeCloseTo(10, 0); + expect(geometry.height).toBeLessThanOrEqual(40.25); + expect(geometry.y + geometry.height).toBeLessThanOrEqual(50.25); + }); + + test('unsafe converter-shaped endnotes retain the conservative whole-block fallback', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + const result = await page.evaluate((html) => { + const pagination = (window as any).DocxodusPagination.paginateHtml(html, 'viewer', { + showPageNumbers: false, + fragmentParagraphs: true, + layoutToken: { documentVersion: 0, rendererFingerprint: 'unsafe-endnote-v1' }, + }); + const fragments = pagination.pageMap.fragments.filter((fragment: any) => + fragment.anchorId === 'p:en:unsafe'); + return { + count: fragments.length, + markedSafe: document.querySelectorAll('[data-pagination-safe-endnote="true"]').length, + }; + }, shell(` +
+

  1. +

    + ${words} ${words} +

    +
+
`)); + expect(result.count).toBe(1); + expect(result.markedSafe).toBe(0); + }); + + test('refuses an available map when the source has no canonical inventory', 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: 'empty-inventory-v1' }, + }); + return ''; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, shell(` +
+

not canonically addressable

+
`)); + expect(message).toContain('without canonical source inventory'); + }); + + test('citation switching and clearing restore the previous highlight exactly', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + const state = await page.evaluate((html) => { + const api = (window as any).DocxodusPagination; + const viewer = document.getElementById('viewer') as HTMLElement; + const result = api.paginateHtml(html, viewer, { + showPageNumbers: false, + layoutToken: { documentVersion: 0, rendererFingerprint: 'highlight-v1' }, + }); + const citation = (anchorId: string) => ({ + availability: 'available', + anchorId, + fragments: result.pageMap.fragments.filter((fragment: any) => fragment.anchorId === anchorId), + }); + const firstCitation = citation('p:body:first'); + const secondCitation = citation('p:body:second'); + const first = Array.from(viewer.querySelectorAll('[data-page-fragment-id]')) + .find((node) => node.dataset.pageFragmentId === firstCitation.fragments[0].fragmentId)!; + const second = Array.from(viewer.querySelectorAll('[data-page-fragment-id]')) + .find((node) => node.dataset.pageFragmentId === secondCitation.fragments[0].fragmentId)!; + first.style.outline = '2px dotted rgb(1, 2, 3)'; + api.navigateToPageCitation(viewer, firstCitation, { behavior: 'auto' }); + api.navigateToPageCitation(viewer, secondCitation, { behavior: 'auto' }); + const firstRestored = first.style.outline; + const secondHighlighted = second.style.outline; + api.clearPageCitationHighlight(viewer); + const secondCleared = second.style.outline; + + first.classList.add('existing-highlight'); + api.navigateToPageCitation(viewer, firstCitation, { + behavior: 'auto', highlightClass: 'existing-highlight', + }); + api.clearPageCitationHighlight(viewer); + return { + firstRestored, + secondHighlighted, + secondCleared, + existingClassRetained: first.classList.contains('existing-highlight'), + }; + }, shell(` +
+

first

+

second

+
`)); + + expect(state.firstRestored).toContain('dotted'); + expect(state.secondHighlighted).toContain('solid'); + expect(state.secondCleared).toBe(''); + expect(state.existingClassRetained).toBe(true); + }); + + test('materializes referenced margin comments in each page side substrate without duplicate ids', async ({ page }) => { + await page.setContent('
'); + await addBundle(page); + const result = await page.evaluate((html) => { + const pagination = (window as any).DocxodusPagination.paginateHtml(html, 'viewer', { + showPageNumbers: false, + layoutToken: { documentVersion: 0, rendererFingerprint: 'margin-comments-v1' }, + }); + const pageBoxes = Array.from(document.querySelectorAll('.page-box')); + return { + totalPages: pagination.totalPages, + commentPages: Array.from(new Set(pagination.pageMap.fragments + .filter((fragment: any) => fragment.story === 'comment') + .map((fragment: any) => fragment.pageNumber))), + marginColumns: pageBoxes.filter((box) => box.querySelector('.page-comment-margin')).length, + pageCommentIds: document.querySelectorAll('#pagination-container [id="comment-7"]').length, + registryCommentIds: document.querySelectorAll( + '#pagination-comment-margin-registry [id="comment-7"]', + ).length, + activeBackrefs: document.querySelectorAll( + '#pagination-container .page-comment-margin a[href^="#"]', + ).length, + }; + }, shell(` + +
+

+ first range +

+

+ second range +

+
`)); + + expect(result.totalPages).toBe(2); + expect(result.commentPages).toEqual([1, 2]); + expect(result.marginColumns).toBe(2); + expect(result.pageCommentIds).toBe(0); + expect(result.registryCommentIds).toBe(1); + expect(result.activeBackrefs).toBe(0); + }); +}); diff --git a/npm/tests/pagination-footnote-layout.spec.ts b/npm/tests/pagination-footnote-layout.spec.ts index 493f31e4..77839c3f 100644 --- a/npm/tests/pagination-footnote-layout.spec.ts +++ b/npm/tests/pagination-footnote-layout.spec.ts @@ -48,6 +48,37 @@ function stagingHtml(bodyBlocks: number, citing: number, noteLines: number): str
`; } +/** The document's final body block cites several whole notes, forcing at least one to defer. */ +function finalBlockDeferralHtml(): string { + const notes = Array.from({ length: 4 }, (_, i) => ` +
+ ${i + 1}. + ${ + Array.from({ length: 10 }, () => `

last note ${i} line

`).join('') + }
+
`).join(''); + const citations = Array.from({ length: 4 }, (_, i) => + `${i + 1}`).join(''); + return ` + +
+
${notes}
+
+

only and final body block ${citations}

+
+
+
`; +} + /** * Geometry of every rendered page. `usedPct` is BODY + NOTES against the content box: notes * legitimately consume page height, so body extent alone understates how full a page is — the @@ -80,9 +111,8 @@ const MEASURE = () => { /** Every note id cited on a page must be rendered somewhere in the document. */ const MEASURE_NOTES = () => { const container = document.getElementById('container') as HTMLElement; - const rendered = new Set( - Array.from(container.querySelectorAll('.footnote-item')).map((i) => i.getAttribute('data-footnote-id')), - ); + const renderedItems = Array.from(container.querySelectorAll('.footnote-item')); + const rendered = new Set(renderedItems.map((i) => i.getAttribute('data-footnote-id'))); const cited = Array.from( new Set(Array.from(container.querySelectorAll('[data-footnote-id]')) .filter((e) => e.tagName === 'SUP' || e.tagName === 'A') @@ -93,6 +123,24 @@ const MEASURE_NOTES = () => { rendered: rendered.size, lost: cited.filter((id) => !rendered.has(id)), nested: container.querySelectorAll('.footnote-item .footnote-item').length, + clipped: renderedItems.filter((item) => { + const band = item.closest('.page-footnotes'); + if (!band) return true; + const itemRect = item.getBoundingClientRect(); + const bandRect = band.getBoundingClientRect(); + return itemRect.top < bandRect.top - 1 || itemRect.bottom > bandRect.bottom + 1; + }).map((item) => { + const band = item.closest('.page-footnotes')!; + const itemRect = item.getBoundingClientRect(); + const bandRect = band.getBoundingClientRect(); + return { + id: item.dataset.footnoteId, + itemTop: Math.round(itemRect.top), + itemBottom: Math.round(itemRect.bottom), + bandTop: Math.round(bandRect.top), + bandBottom: Math.round(bandRect.bottom), + }; + }), }; }; @@ -173,4 +221,31 @@ test.describe('Paginated footnote layout', () => { `pages left nearly empty: ${JSON.stringify(wasteful)} of ${pages.length}`, ).toEqual([]); }); + + test('whole notes deferred from the final body block drain onto note-only pages', async ({ page }) => { + await page.setContent(finalBlockDeferralHtml()); + await page.addScriptTag({ path: path.join(__dirname, '../dist/pagination.bundle.js') }); + const result = await page.evaluate( + ({ measure }: { measure: string }) => { + const staging = document.getElementById('staging') as HTMLElement; + const container = document.getElementById('container') as HTMLElement; + const { PaginationEngine } = (window as any).DocxodusPagination; + new PaginationEngine(staging, container, { showPageNumbers: false }).paginate(); + // eslint-disable-next-line no-eval + const notes = (0, eval)(`(${measure})`)(); + return { + ...notes, + noteOnlyPages: Array.from(container.querySelectorAll('.page-box')).filter((box) => + box.querySelector('.page-footnotes') && !box.querySelector('.page-content')?.children.length, + ).length, + }; + }, + { measure: MEASURE_NOTES.toString() }, + ); + expect(result.cited).toBe(4); + expect(result.lost, `notes cited but never rendered: ${JSON.stringify(result.lost)}`).toEqual([]); + expect(result.clipped, `notes rendered outside their visible band: ${JSON.stringify(result.clipped)}`) + .toEqual([]); + expect(result.noteOnlyPages).toBeGreaterThan(0); + }); }); 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..f0a71fa3 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,177 @@ 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 + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "PageMapPage": + return cls( + page_number=int(d["pageNumber"]), + page_in_section=int(d["pageInSection"]), + width=float(d["width"]), + height=float(d["height"]), + page_name=d["pageName"], + section_index=int(d["sectionIndex"]) if d.get("sectionIndex") is not None else None, + ) + + +@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 + pages: tuple[PageMapPage, ...] = () + 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", ""), + pages=tuple(PageMapPage._from_wire(p) for p in d.get("pages", ())), + 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 +934,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 +946,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 +977,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 +988,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 +1202,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 +1219,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 +1370,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 +1379,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..2c10dd22 --- /dev/null +++ b/python/tests/test_page_map.py @@ -0,0 +1,68 @@ +"""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.pages[0].width == 612 + 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..a77dbd79 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,53 @@ private static string Preview(SessionStore store, JsonElement args) { var session = Session(store, args); var anchorId = OptStr(args, "anchorId"); - var html = anchorId is null - ? DocxSessionOps.RenderHtml(session.Handle, "docx-", false, false, 1.0) - : DocxSessionOps.RenderBlockHtml(session.Handle, anchorId, "docx-", false); + var citationRequest = DocxSessionJson.ParsePageCitationRequest(args); + var citationJson = anchorId is not null && citationRequest is not null + ? DocxSessionOps.GetPageCitation(session.Handle, anchorId, citationRequest) + : null; + var hasPhysicalCitation = false; + if (citationJson is not null) + { + using var citationDoc = JsonDocument.Parse(citationJson); + hasPhysicalCitation = citationDoc.RootElement.GetProperty("availability").GetString() + == "available"; + } + // A cited preview carries paginated converter staging so the browser widget can project + // the exact registered page geometry without inventing layout. Ordinary preview remains + // the established lightweight continuous/block render. + var html = hasPhysicalCitation + ? DocxSessionOps.RenderHtml(session.Handle, "docx-", false, true, 1.0) + : anchorId is null + ? DocxSessionOps.RenderHtml(session.Handle, "docx-", false, false, 1.0) + : DocxSessionOps.RenderBlockHtml(session.Handle, anchorId, "docx-", false); return $"{{\"sessionId\":{JsonRpcIo.JsonString(session.Id)}" + (anchorId is null ? "" : $",\"anchorId\":{JsonRpcIo.JsonString(anchorId)}") + + (citationJson is null ? "" : $",\"citation\":{citationJson}") + + (hasPhysicalCitation + ? ",\"pageNavigation\":\"available_registered_map\"" + : ",\"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 +269,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 +896,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..734dc2ff 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,14 @@ 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", "additionalProperties": false, + "properties": { + "documentVersion": { "type": "integer", "minimum": 0 }, + "rendererFingerprint": { "type": "string", "minLength": 1 } + }, + "required": ["documentVersion", "rendererFingerprint"] + }, "preconditions": { "type": "object", "description": "check_preconditions: expectedVersion and/or anchorId plus expectedContentHash, expectedText/expectedTextRange, expectedKind, expectedScope, or expectedMatchCount." } }, "required": ["sessionId", "format"] @@ -73,17 +81,104 @@ 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. With an exact registered citation, the widget materializes the cited physical page and navigates to its highlighted fragment. 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", "additionalProperties": false, + "properties": { + "documentVersion": { "type": "integer", "minimum": 0 }, + "rendererFingerprint": { "type": "string", "minLength": 1 } + }, + "required": ["documentVersion", "rendererFingerprint"] + } }, "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", "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "const": 1 }, + "mode": { "type": "string", "enum": ["paginated", "continuous"] }, + "availability": { "type": "string", "enum": ["available", "unavailable"] }, + "documentVersion": { "type": "integer", "minimum": 0 }, + "rendererFingerprint": { "type": "string", "minLength": 1 }, + "pages": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, + "properties": { + "pageNumber": { "type": "integer", "minimum": 1 }, + "pageInSection": { "type": "integer", "minimum": 1 }, + "width": { "type": "number", "exclusiveMinimum": 0 }, + "height": { "type": "number", "exclusiveMinimum": 0 }, + "sectionIndex": { "type": "integer", "minimum": 0 }, + "pageName": { "type": "string", "minLength": 1 } + }, + "required": ["pageNumber", "pageInSection", "width", "height", "pageName"] + } + }, + "fragments": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, + "properties": { + "fragmentId": { "type": "string", "minLength": 1 }, + "anchorId": { "type": "string", "minLength": 1 }, + "fragmentIndex": { "type": "integer", "minimum": 0 }, + "pageNumber": { "type": "integer", "minimum": 1 }, + "geometry": { + "type": "object", "additionalProperties": false, + "properties": { + "x": { "type": "number", "minimum": 0 }, + "y": { "type": "number", "minimum": 0 }, + "width": { "type": "number", "exclusiveMinimum": 0 }, + "height": { "type": "number", "exclusiveMinimum": 0 } + }, + "required": ["x", "y", "width", "height"] + }, + "story": { "type": "string", "enum": ["body", "header", "footer", "footnote", "endnote", "comment"] }, + "inTableCell": { "type": "boolean" } + }, + "required": ["fragmentId", "anchorId", "fragmentIndex", "pageNumber", "geometry", "story", "inTableCell"] + } + } + }, + "required": ["schemaVersion", "mode", "availability", "documentVersion", "rendererFingerprint", "pages", "fragments"] + }, + "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", "additionalProperties": false, + "properties": { + "documentVersion": { "type": "integer", "minimum": 0 }, + "rendererFingerprint": { "type": "string", "minLength": 1 } + }, + "required": ["documentVersion", "rendererFingerprint"] + } + }, + "required": ["sessionId", "action"], + "oneOf": [ + { "properties": { "action": { "const": "register" } }, "required": ["pageMap"] }, + { "properties": { "action": { "const": "status" } } }, + { "properties": { "action": { "const": "cite" } }, "required": ["anchorId", "citation"] } + ] + } + """), 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 +193,14 @@ 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", "additionalProperties": false, + "properties": { + "documentVersion": { "type": "integer", "minimum": 0 }, + "rendererFingerprint": { "type": "string", "minLength": 1 } + }, + "required": ["documentVersion", "rendererFingerprint"] + } }, "required": ["sessionId", "mode", "query"] } diff --git a/tools/mcp-server/UiResources.cs b/tools/mcp-server/UiResources.cs index a34705dc..527ef516 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,13 @@ 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-citation-page { position: relative; overflow: hidden; margin: 0 auto 20px; + background: white; box-shadow: 0 2px 12px rgba(0,0,0,.28); } + .dxo-page-label { position: absolute; top: 4pt; right: 6pt; color: #777; + font: 9pt/1 system-ui, sans-serif; } + .dxo-citation-fragment { position: absolute; overflow: visible; box-sizing: border-box; } + .dxo-cited-fragment { outline: 3px solid #f4b400 !important; + outline-offset: 2px; background-color: rgba(255, 235, 59, .18) !important; } @@ -157,7 +168,8 @@ public static string WrapToolResult(string toolName, string resultJson, bool isE