docxodus 9.8.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -0
- package/dist/canonical.d.ts +7 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +56 -0
- package/dist/canonical.js.map +1 -0
- package/dist/docxodus.worker.d.ts +2 -1
- package/dist/docxodus.worker.d.ts.map +1 -1
- package/dist/docxodus.worker.js +244 -3
- package/dist/docxodus.worker.js.map +1 -1
- package/dist/editor.bundle.js +2024 -388
- package/dist/editor.d.ts +22 -2
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +94 -9
- package/dist/editor.js.map +1 -1
- package/dist/embed.bundle.js +2912 -437
- package/dist/embed.iife.js +2911 -436
- package/dist/export-assets.json +283 -0
- package/dist/export-browser.bundle.js +7990 -0
- package/dist/export-browser.d.ts +266 -0
- package/dist/export-browser.d.ts.map +1 -0
- package/dist/export-browser.js +2915 -0
- package/dist/export-browser.js.map +1 -0
- package/dist/export-resource-limits-v1.json +57 -0
- package/dist/font-contract.d.ts +150 -0
- package/dist/font-contract.d.ts.map +1 -0
- package/dist/font-contract.js +59 -0
- package/dist/font-contract.js.map +1 -0
- package/dist/font-runtime.d.ts +33 -0
- package/dist/font-runtime.d.ts.map +1 -0
- package/dist/font-runtime.js +1100 -0
- package/dist/font-runtime.js.map +1 -0
- package/dist/index.d.ts +62 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +130 -16
- package/dist/index.js.map +1 -1
- package/dist/page-geometry.d.ts +8 -1
- package/dist/page-geometry.d.ts.map +1 -1
- package/dist/page-geometry.js +37 -11
- package/dist/page-geometry.js.map +1 -1
- package/dist/pagination.bundle.js +1937 -380
- package/dist/pagination.d.ts +277 -15
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +2055 -438
- package/dist/pagination.js.map +1 -1
- package/dist/react.d.ts +11 -4
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +38 -5
- package/dist/react.js.map +1 -1
- package/dist/render-report-v2.schema.json +1581 -0
- package/dist/ribbon-chrome.d.ts +1 -1
- package/dist/ribbon-chrome.d.ts.map +1 -1
- package/dist/ribbon-chrome.js +46 -0
- package/dist/ribbon-chrome.js.map +1 -1
- package/dist/ribbon.js +70 -0
- package/dist/ribbon.js.map +1 -1
- package/dist/session.bundle.js +695 -29
- package/dist/session.d.ts +134 -19
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -25
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +1207 -16
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm.br +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm.br +0 -0
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm.br +0 -0
- package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Immutable.wasm.br +0 -0
- package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.NonGeneric.wasm.br +0 -0
- package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Specialized.wasm.br +0 -0
- package/dist/wasm/_framework/System.Collections.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.wasm.br +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm.br +0 -0
- package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm.br +0 -0
- package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.wasm.br +0 -0
- package/dist/wasm/_framework/System.Console.wasm +0 -0
- package/dist/wasm/_framework/System.Console.wasm.br +0 -0
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm.br +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
- package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Packaging.wasm.br +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm.br +0 -0
- package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.Expressions.wasm.br +0 -0
- package/dist/wasm/_framework/System.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
- package/dist/wasm/_framework/System.Memory.wasm +0 -0
- package/dist/wasm/_framework/System.Memory.wasm.br +0 -0
- package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Http.wasm.br +0 -0
- package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Primitives.wasm.br +0 -0
- package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
- package/dist/wasm/_framework/System.ObjectModel.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm.br +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm.br +0 -0
- package/dist/wasm/_framework/System.Runtime.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.wasm.br +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm.br +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm.br +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm.br +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
- package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.Linq.wasm.br +0 -0
- package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XDocument.wasm.br +0 -0
- package/dist/wasm/_framework/System.wasm +0 -0
- package/dist/wasm/_framework/System.wasm.br +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +38 -38
- package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
- package/dist/wasm/_framework/dotnet.js +1 -1
- package/dist/wasm/_framework/dotnet.js.br +0 -0
- package/dist/wasm/_framework/dotnet.native.js +3 -3
- package/dist/wasm/_framework/dotnet.native.js.br +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
- package/dist/wasm/_framework/dotnet.runtime.js +1 -1
- package/dist/wasm/_framework/dotnet.runtime.js.br +0 -0
- package/dist/worker-proxy.bundle.js +196 -29
- package/dist/worker-proxy.d.ts +36 -3
- package/dist/worker-proxy.d.ts.map +1 -1
- package/dist/worker-proxy.js +186 -33
- package/dist/worker-proxy.js.map +1 -1
- package/package.json +24 -7
package/dist/pagination.js
CHANGED
|
@@ -6,12 +6,124 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { formatPageNumber } from "./page-number-format.js";
|
|
8
8
|
import { DEFAULT_MARGIN, DEFAULT_PAGE_WIDTH, parseSectionDimensions, ptToPx, pxToPt, resolvePageBands, } from "./page-geometry.js";
|
|
9
|
+
/**
|
|
10
|
+
* Whether two sections describe the same physical page box. A continuous section
|
|
11
|
+
* break is only honorable when it does — a changed page size or margin set forces
|
|
12
|
+
* a real page break, which is Word's behavior too.
|
|
13
|
+
*/
|
|
14
|
+
function samePageBox(a, b) {
|
|
15
|
+
return (a.pageWidth === b.pageWidth &&
|
|
16
|
+
a.pageHeight === b.pageHeight &&
|
|
17
|
+
a.marginTop === b.marginTop &&
|
|
18
|
+
a.marginRight === b.marginRight &&
|
|
19
|
+
a.marginBottom === b.marginBottom &&
|
|
20
|
+
a.marginLeft === b.marginLeft);
|
|
21
|
+
}
|
|
22
|
+
/** Explicit no-pages contract for a continuous viewer. It never estimates page numbers. */
|
|
23
|
+
export function createUnavailablePageMap(documentVersion, rendererFingerprint, mode = "continuous") {
|
|
24
|
+
if (!rendererFingerprint)
|
|
25
|
+
throw new Error("rendererFingerprint must be non-empty");
|
|
26
|
+
return {
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
mode,
|
|
29
|
+
availability: "unavailable",
|
|
30
|
+
documentVersion,
|
|
31
|
+
rendererFingerprint,
|
|
32
|
+
pages: [],
|
|
33
|
+
fragments: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const activePageCitationHighlights = new WeakMap();
|
|
37
|
+
/** Remove the citation highlight previously applied within this paginated root. */
|
|
38
|
+
export function clearPageCitationHighlight(root) {
|
|
39
|
+
const active = activePageCitationHighlights.get(root);
|
|
40
|
+
if (!active)
|
|
41
|
+
return;
|
|
42
|
+
if (active.highlightClass && active.addedClass) {
|
|
43
|
+
active.target.classList.remove(active.highlightClass);
|
|
44
|
+
}
|
|
45
|
+
for (const style of active.inlineStyles ?? []) {
|
|
46
|
+
if (style.value) {
|
|
47
|
+
active.target.style.setProperty(style.name, style.value, style.priority);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
active.target.style.removeProperty(style.name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
activePageCitationHighlights.delete(root);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Navigate an exact citation over an already-paginated DOM. The page-qualified fragment id is
|
|
57
|
+
* authoritative; page + canonical source identity is a compatibility fallback for older v1 DOMs.
|
|
58
|
+
*/
|
|
59
|
+
export function navigateToPageCitation(root, citation, options = {}) {
|
|
60
|
+
clearPageCitationHighlight(root);
|
|
61
|
+
if (citation.availability !== "available" || citation.fragments.length === 0) {
|
|
62
|
+
return { navigated: false, unavailableReason: "citation_unavailable" };
|
|
63
|
+
}
|
|
64
|
+
const byAttribute = (name, value, within = root) => {
|
|
65
|
+
for (const node of Array.from(within.querySelectorAll(`[${name}]`))) {
|
|
66
|
+
if (node.getAttribute(name) === value)
|
|
67
|
+
return node;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
const fragment = citation.fragments[0];
|
|
72
|
+
let target = byAttribute("data-page-fragment-id", fragment.fragmentId);
|
|
73
|
+
if (!target) {
|
|
74
|
+
const page = byAttribute("data-page-number", String(fragment.pageNumber));
|
|
75
|
+
if (page)
|
|
76
|
+
target = byAttribute("data-source-anchor-id", citation.anchorId, page) ?? page;
|
|
77
|
+
}
|
|
78
|
+
if (!target) {
|
|
79
|
+
return {
|
|
80
|
+
navigated: false,
|
|
81
|
+
pageNumber: fragment.pageNumber,
|
|
82
|
+
fragmentId: fragment.fragmentId,
|
|
83
|
+
unavailableReason: "fragment_not_found",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (options.highlightClass) {
|
|
87
|
+
const addedClass = !target.classList.contains(options.highlightClass);
|
|
88
|
+
target.classList.add(options.highlightClass);
|
|
89
|
+
activePageCitationHighlights.set(root, {
|
|
90
|
+
target,
|
|
91
|
+
highlightClass: options.highlightClass,
|
|
92
|
+
addedClass,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else if (options.highlight !== false) {
|
|
96
|
+
const names = ["outline", "outline-offset", "background-color"];
|
|
97
|
+
const inlineStyles = names.map((name) => ({
|
|
98
|
+
name,
|
|
99
|
+
value: target.style.getPropertyValue(name),
|
|
100
|
+
priority: target.style.getPropertyPriority(name),
|
|
101
|
+
}));
|
|
102
|
+
target.style.setProperty("outline", "3px solid #f4b400", "important");
|
|
103
|
+
target.style.setProperty("outline-offset", "2px", "important");
|
|
104
|
+
target.style.setProperty("background-color", "rgba(255, 235, 59, .18)", "important");
|
|
105
|
+
activePageCitationHighlights.set(root, { target, inlineStyles });
|
|
106
|
+
}
|
|
107
|
+
target.scrollIntoView({
|
|
108
|
+
behavior: options.behavior ?? "smooth",
|
|
109
|
+
block: options.block ?? "center",
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
navigated: true,
|
|
113
|
+
target,
|
|
114
|
+
pageNumber: fragment.pageNumber,
|
|
115
|
+
fragmentId: fragment.fragmentId,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
9
118
|
// Default letter size in points (612 x 792 = 8.5" x 11")
|
|
10
119
|
// Maximum percentage of content height that footnotes can occupy
|
|
11
120
|
// This allows footnotes to expand upward into body content space when needed
|
|
12
121
|
const MAX_FOOTNOTE_AREA_RATIO = 0.6; // 60% of content height
|
|
13
|
-
//
|
|
14
|
-
|
|
122
|
+
// Hidden measurement and final absolutely-positioned note bands differ by sub-pixel border/margin
|
|
123
|
+
// rounding in Chromium. Reserve a small physical-unit guard so the last baseline stays visible.
|
|
124
|
+
const FOOTNOTE_MEASUREMENT_GUARD_PT = 2;
|
|
125
|
+
/** Internal provenance retained only while a note element waits between pages. */
|
|
126
|
+
const FOOTNOTE_SOURCE_POSITION_ATTR = "data-pagination-footnote-source-position";
|
|
15
127
|
export class PaginationEngine {
|
|
16
128
|
/**
|
|
17
129
|
* Creates a new pagination engine.
|
|
@@ -21,16 +133,28 @@ export class PaginationEngine {
|
|
|
21
133
|
* @param options - Pagination options
|
|
22
134
|
*/
|
|
23
135
|
constructor(staging, container, options = {}) {
|
|
136
|
+
this.createdPageCount = 0;
|
|
137
|
+
this.footnoteSeparator = null;
|
|
138
|
+
this.footnoteContinuationSeparator = null;
|
|
139
|
+
this.footnoteLayoutLikeWord8 = false;
|
|
24
140
|
this.pendingFootnoteContinuation = null;
|
|
25
141
|
/** Per-section `w:pgNumType` (start / format), read off the section wrappers. */
|
|
26
142
|
this.pageNumbering = new Map();
|
|
143
|
+
this.lastPages = [];
|
|
144
|
+
this.expectedPageMapAnchorIds = new Set();
|
|
145
|
+
this.state = "ready";
|
|
146
|
+
const ownerDocument = typeof staging !== "string"
|
|
147
|
+
? staging.ownerDocument
|
|
148
|
+
: typeof container !== "string"
|
|
149
|
+
? container.ownerDocument
|
|
150
|
+
: globalThis.document;
|
|
27
151
|
this.stagingElement =
|
|
28
152
|
typeof staging === "string"
|
|
29
|
-
?
|
|
153
|
+
? ownerDocument.getElementById(staging)
|
|
30
154
|
: staging;
|
|
31
155
|
this.containerElement =
|
|
32
156
|
typeof container === "string"
|
|
33
|
-
?
|
|
157
|
+
? ownerDocument.getElementById(container)
|
|
34
158
|
: container;
|
|
35
159
|
if (!this.stagingElement) {
|
|
36
160
|
throw new Error("Staging element not found");
|
|
@@ -38,13 +162,27 @@ export class PaginationEngine {
|
|
|
38
162
|
if (!this.containerElement) {
|
|
39
163
|
throw new Error("Container element not found");
|
|
40
164
|
}
|
|
165
|
+
if (this.stagingElement.ownerDocument !== this.containerElement.ownerDocument) {
|
|
166
|
+
throw new Error("Staging and container elements must belong to the same document");
|
|
167
|
+
}
|
|
168
|
+
const view = ownerDocument.defaultView;
|
|
169
|
+
if (!view) {
|
|
170
|
+
throw new Error("Pagination requires an attached document with a defaultView");
|
|
171
|
+
}
|
|
172
|
+
this.document = ownerDocument;
|
|
173
|
+
this.view = view;
|
|
41
174
|
this.scale = options.scale ?? 1;
|
|
42
175
|
this.cssPrefix = options.cssPrefix ?? "page-";
|
|
43
176
|
this.showPageNumbers = options.showPageNumbers ?? true;
|
|
44
177
|
this.pageGap = options.pageGap ?? 20;
|
|
45
178
|
this.fragmentParagraphs = options.fragmentParagraphs ?? false;
|
|
179
|
+
this.deferFragmentIdentities = options.deferFragmentIdentities ?? false;
|
|
180
|
+
this.cancellationCheckpoint = options.checkCancellation;
|
|
181
|
+
this.pageCountCheckpoint = options.checkPageCount;
|
|
182
|
+
this.layoutToken = options.layoutToken;
|
|
46
183
|
this.hfRegistry = new Map();
|
|
47
184
|
this.footnoteRegistry = new Map();
|
|
185
|
+
this.commentMarginRegistry = new Map();
|
|
48
186
|
}
|
|
49
187
|
/**
|
|
50
188
|
* Runs the pagination process.
|
|
@@ -52,39 +190,524 @@ export class PaginationEngine {
|
|
|
52
190
|
* @returns PaginationResult with page information
|
|
53
191
|
*/
|
|
54
192
|
paginate() {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
this.
|
|
71
|
-
|
|
72
|
-
this.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
193
|
+
this.checkpoint();
|
|
194
|
+
if (this.state !== "ready") {
|
|
195
|
+
throw new Error(`PaginationEngine is one-shot and is already ${this.state}`);
|
|
196
|
+
}
|
|
197
|
+
if (!this.stagingElement.isConnected || !this.containerElement.isConnected) {
|
|
198
|
+
throw new Error("Pagination requires staging and container elements attached to their document");
|
|
199
|
+
}
|
|
200
|
+
if (this.document.documentElement.getBoundingClientRect().width <= 0) {
|
|
201
|
+
throw new Error("Pagination requires a browsing context with non-zero layout");
|
|
202
|
+
}
|
|
203
|
+
this.state = "running";
|
|
204
|
+
try {
|
|
205
|
+
const pages = [];
|
|
206
|
+
let pageNumber = 1;
|
|
207
|
+
// Parse the header/footer registry if present
|
|
208
|
+
this.hfRegistry = this.parseHeaderFooterRegistry();
|
|
209
|
+
// Parse the footnote registry if present
|
|
210
|
+
this.footnoteRegistry = this.parseFootnoteRegistry();
|
|
211
|
+
({
|
|
212
|
+
normal: this.footnoteSeparator,
|
|
213
|
+
continuation: this.footnoteContinuationSeparator,
|
|
214
|
+
} = this.parseFootnoteSeparators());
|
|
215
|
+
// Parse the margin-comment registry if present. Its entries are cloned into
|
|
216
|
+
// the side substrate of pages that contain the corresponding range marker.
|
|
217
|
+
this.commentMarginRegistry = this.parseCommentMarginRegistry();
|
|
218
|
+
this.footnoteLayoutLikeWord8 =
|
|
219
|
+
this.stagingElement.dataset.footnoteLayoutLikeWord8 === "true";
|
|
220
|
+
// Find all section containers
|
|
221
|
+
const sections = this.stagingElement.querySelectorAll("[data-section-index]");
|
|
222
|
+
this.pageNumbering = this.parsePageNumbering(sections);
|
|
223
|
+
// If no sections found, treat the entire staging content as one section
|
|
224
|
+
const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
|
|
225
|
+
// Snapshot the addressable SOURCE inventory before flow moves nodes out of staging. PageMap
|
|
226
|
+
// completeness cannot be inferred from whatever survives into page boxes: that would let a
|
|
227
|
+
// dropped block silently disappear from both the DOM and the supposedly authoritative map.
|
|
228
|
+
// Running-story variants and cited notes are inventoried from their source registries.
|
|
229
|
+
this.expectedPageMapAnchorIds = new Set();
|
|
230
|
+
const referencedFootnoteIds = new Set();
|
|
231
|
+
const referencedCommentIds = new Set();
|
|
232
|
+
for (const section of sectionsToProcess) {
|
|
233
|
+
this.checkpoint();
|
|
234
|
+
this.collectExpectedSourceAnchors(section, this.expectedPageMapAnchorIds, true);
|
|
235
|
+
for (const reference of Array.from(section.querySelectorAll("[data-footnote-id]"))) {
|
|
236
|
+
this.checkpoint();
|
|
237
|
+
if (reference.closest("#pagination-footnote-registry, #pagination-hf-registry"))
|
|
238
|
+
continue;
|
|
239
|
+
const id = reference.dataset.footnoteId;
|
|
240
|
+
if (id)
|
|
241
|
+
referencedFootnoteIds.add(id);
|
|
242
|
+
}
|
|
243
|
+
for (const reference of Array.from(section.querySelectorAll("[data-comment-id]"))) {
|
|
244
|
+
this.checkpoint();
|
|
245
|
+
if (reference.closest("#pagination-comment-margin-registry, #pagination-footnote-registry, #pagination-hf-registry"))
|
|
246
|
+
continue;
|
|
247
|
+
const id = reference.dataset.commentId;
|
|
248
|
+
if (id)
|
|
249
|
+
referencedCommentIds.add(id);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (const id of referencedFootnoteIds) {
|
|
253
|
+
this.checkpoint();
|
|
254
|
+
const source = this.footnoteRegistry.get(id);
|
|
255
|
+
if (!source)
|
|
256
|
+
continue;
|
|
257
|
+
this.collectExpectedSourceAnchors(source, this.expectedPageMapAnchorIds);
|
|
258
|
+
// Margin comments are presentation stories selected by markers. Markers
|
|
259
|
+
// inside a cited footnote are just as visible/addressable as body markers.
|
|
260
|
+
for (const reference of Array.from(source.querySelectorAll("[data-comment-id]"))) {
|
|
261
|
+
this.checkpoint();
|
|
262
|
+
const commentId = reference.dataset.commentId;
|
|
263
|
+
if (commentId)
|
|
264
|
+
referencedCommentIds.add(commentId);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const id of referencedCommentIds) {
|
|
268
|
+
this.checkpoint();
|
|
269
|
+
const source = this.commentMarginRegistry.get(id);
|
|
270
|
+
if (source)
|
|
271
|
+
this.collectExpectedSourceAnchors(source, this.expectedPageMapAnchorIds);
|
|
272
|
+
}
|
|
273
|
+
const runs = [];
|
|
274
|
+
for (const section of sectionsToProcess) {
|
|
275
|
+
this.checkpoint();
|
|
276
|
+
const dims = parseSectionDimensions(section);
|
|
277
|
+
const previous = runs[runs.length - 1];
|
|
278
|
+
if (previous &&
|
|
279
|
+
section.dataset.sectionType === "continuous" &&
|
|
280
|
+
samePageBox(previous.dims, dims)) {
|
|
281
|
+
previous.sections.push(section);
|
|
282
|
+
previous.sectionDimensions.set(parseInt(section.dataset.sectionIndex || "0", 10), dims);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
|
|
286
|
+
runs.push({
|
|
287
|
+
sections: [section],
|
|
288
|
+
sectionIndex,
|
|
289
|
+
sectionType: section.dataset.sectionType ?? "nextPage",
|
|
290
|
+
dims,
|
|
291
|
+
sectionDimensions: new Map([[sectionIndex, dims]]),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const run of runs) {
|
|
296
|
+
this.checkpoint();
|
|
297
|
+
// Odd/even section breaks begin the new section on the requested physical side. When the
|
|
298
|
+
// following page has the opposite parity, Word inserts one intentionally blank filler page.
|
|
299
|
+
// It belongs to the preceding section, advances physical/logical numbering, retains that
|
|
300
|
+
// section's paper geometry, and carries no running story.
|
|
301
|
+
const needsParityFiller = pages.length > 0
|
|
302
|
+
&& ((run.sectionType === "oddPage" && pageNumber % 2 === 0)
|
|
303
|
+
|| (run.sectionType === "evenPage" && pageNumber % 2 === 1));
|
|
304
|
+
if (needsParityFiller) {
|
|
305
|
+
const precedingPage = pages[pages.length - 1];
|
|
306
|
+
const precedingPageInSection = parseInt(precedingPage.element.dataset.pageInSection ?? "1", 10);
|
|
307
|
+
const precedingDisplayedPageNumber = parseInt(precedingPage.element.dataset.displayedPageNumber ?? String(precedingPage.pageNumber), 10);
|
|
308
|
+
const filler = this.createPage(precedingPage.dimensions, pageNumber, precedingPage.sectionIndex, precedingDisplayedPageNumber + 1, [], precedingPageInSection + 1, [], 0, null, undefined, true);
|
|
309
|
+
pages.push(filler);
|
|
310
|
+
pageNumber++;
|
|
311
|
+
}
|
|
312
|
+
// Make staging visible for measurement
|
|
313
|
+
this.stagingElement.style.visibility = "hidden";
|
|
314
|
+
this.stagingElement.style.position = "absolute";
|
|
315
|
+
this.stagingElement.style.left = "-9999px";
|
|
316
|
+
this.stagingElement.style.display = "block";
|
|
317
|
+
const blocks = [];
|
|
318
|
+
for (const section of run.sections) {
|
|
319
|
+
this.checkpoint();
|
|
320
|
+
const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
|
|
321
|
+
const sectionDims = run.sectionDimensions.get(sectionIndex) ?? run.dims;
|
|
322
|
+
// Set width for accurate line wrapping
|
|
323
|
+
section.style.width = `${sectionDims.contentWidth}pt`;
|
|
324
|
+
const columnCount = parseInt(section.dataset.cols || "1", 10);
|
|
325
|
+
if (columnCount > 1) {
|
|
326
|
+
const gap = parseFloat(section.dataset.colGap || "");
|
|
327
|
+
blocks.push(...this.buildColumnBlocks(section, sectionDims, sectionIndex, columnCount, Number.isFinite(gap) ? gap : 36));
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
blocks.push(...this.measureBlocks(section, sectionDims, sectionIndex));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Flow blocks into pages
|
|
334
|
+
const sectionPages = this.flowToPages(blocks, run.dims, pageNumber, run.sectionIndex, run.sectionDimensions);
|
|
335
|
+
this.checkpoint();
|
|
336
|
+
pages.push(...sectionPages);
|
|
337
|
+
pageNumber += sectionPages.length;
|
|
338
|
+
}
|
|
339
|
+
// Hide staging after measurement
|
|
340
|
+
this.stagingElement.style.display = "none";
|
|
341
|
+
// Every page box exists now, so NUMPAGES has an answer and each PAGE marker knows its page.
|
|
342
|
+
this.substitutePageNumberFields(pages.length);
|
|
343
|
+
// Only running-story variants selected by a real page are expected to materialize. Read IDs
|
|
344
|
+
// from registry sources, not presentation clones, so a failed clone remains detectable.
|
|
345
|
+
for (const page of pages) {
|
|
346
|
+
this.checkpoint();
|
|
347
|
+
if (page.element.dataset.sectionFiller === "true")
|
|
348
|
+
continue;
|
|
349
|
+
const pageInSection = parseInt(page.element.dataset.pageInSection || "1", 10);
|
|
350
|
+
const displayedPageNumber = parseInt(page.element.dataset.displayedPageNumber || String(page.pageNumber), 10);
|
|
351
|
+
const header = this.selectHeader(page.sectionIndex, pageInSection, displayedPageNumber);
|
|
352
|
+
const footer = this.selectFooter(page.sectionIndex, pageInSection, displayedPageNumber);
|
|
353
|
+
if (header)
|
|
354
|
+
this.collectExpectedSourceAnchors(header, this.expectedPageMapAnchorIds);
|
|
355
|
+
if (footer)
|
|
356
|
+
this.collectExpectedSourceAnchors(footer, this.expectedPageMapAnchorIds);
|
|
357
|
+
}
|
|
358
|
+
// Establish one active editor anchor and page-qualify every presentation fragment.
|
|
359
|
+
// Full canonical source identities remain on all clones, including table cells.
|
|
360
|
+
this.qualifyPageFragments(pages);
|
|
361
|
+
this.transferVisibleFragmentTargets();
|
|
362
|
+
if (!this.deferFragmentIdentities)
|
|
363
|
+
this.normalizeVisiblePageFragments(pages);
|
|
364
|
+
this.lastPages = pages;
|
|
365
|
+
const result = {
|
|
366
|
+
totalPages: pages.length,
|
|
367
|
+
pages,
|
|
368
|
+
pageMap: this.layoutToken
|
|
369
|
+
? this.materializePageMap(this.layoutToken.documentVersion, this.layoutToken.rendererFingerprint)
|
|
370
|
+
: undefined,
|
|
371
|
+
};
|
|
372
|
+
this.state = "complete";
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
this.state = "failed";
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
checkpoint() {
|
|
381
|
+
this.cancellationCheckpoint?.();
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Normalize visible fragment identities after a caller applies final standalone styles. This
|
|
385
|
+
* deliberately runs before the stability barrier; materializePageMap is read-only so PageMap
|
|
386
|
+
* measurement cannot mutate a tree after it was declared stable.
|
|
387
|
+
*/
|
|
388
|
+
normalizePageMapFragmentIdentities() {
|
|
389
|
+
if (this.lastPages.length === 0) {
|
|
390
|
+
throw new Error("paginate() must complete before fragment identities can be normalized");
|
|
391
|
+
}
|
|
392
|
+
this.normalizeVisiblePageFragments(this.lastPages);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Materialize the last completed browser layout as portable page-relative point geometry.
|
|
396
|
+
* The caller supplies both invalidation tokens; this engine never guesses a document version
|
|
397
|
+
* or renderer fingerprint.
|
|
398
|
+
*/
|
|
399
|
+
materializePageMap(documentVersion, rendererFingerprint) {
|
|
400
|
+
if (!Number.isSafeInteger(documentVersion) || documentVersion < 0) {
|
|
401
|
+
throw new Error("documentVersion must be a non-negative safe integer");
|
|
402
|
+
}
|
|
403
|
+
if (!rendererFingerprint)
|
|
404
|
+
throw new Error("rendererFingerprint must be non-empty");
|
|
405
|
+
if (this.lastPages.length === 0)
|
|
406
|
+
throw new Error("paginate() must complete before materializePageMap()");
|
|
407
|
+
const pages = this.lastPages.map((page) => ({
|
|
408
|
+
pageNumber: page.pageNumber,
|
|
409
|
+
pageInSection: parseInt(page.element.dataset.pageInSection || "1", 10),
|
|
410
|
+
width: page.dimensions.pageWidth,
|
|
411
|
+
height: page.dimensions.pageHeight,
|
|
412
|
+
sectionIndex: page.sectionIndex,
|
|
413
|
+
pageName: `docxodus-section-${page.sectionIndex}`,
|
|
414
|
+
}));
|
|
415
|
+
const fragments = [];
|
|
416
|
+
const requiredAnchorIds = new Set(this.expectedPageMapAnchorIds);
|
|
417
|
+
if (requiredAnchorIds.size === 0) {
|
|
418
|
+
throw new Error("cannot publish an available PageMap without canonical source inventory");
|
|
419
|
+
}
|
|
420
|
+
const measuredAnchorIds = new Set();
|
|
421
|
+
const emittedFragmentCounts = new Map();
|
|
422
|
+
for (const page of this.lastPages) {
|
|
423
|
+
this.checkpoint();
|
|
424
|
+
const pageRect = page.element.getBoundingClientRect();
|
|
425
|
+
if (pageRect.width <= 0 || pageRect.height <= 0) {
|
|
426
|
+
throw new Error(`page ${page.pageNumber} has no measurable geometry`);
|
|
427
|
+
}
|
|
428
|
+
// Ratio-to-known-page-size removes CSS px, zoom, and transform from the contract.
|
|
429
|
+
const pointPerRenderedX = page.dimensions.pageWidth / pageRect.width;
|
|
430
|
+
const pointPerRenderedY = page.dimensions.pageHeight / pageRect.height;
|
|
431
|
+
const nodes = page.element.querySelectorAll("[data-source-anchor-id]");
|
|
432
|
+
for (const element of Array.from(nodes)) {
|
|
433
|
+
this.checkpoint();
|
|
434
|
+
// Preserve the source-side exclusion contract on presentation clones as well. This
|
|
435
|
+
// covers the node itself and any excluded/hidden/aria-hidden ancestor within the page.
|
|
436
|
+
if (this.isDeliberatelyUnrenderedSource(element, page.element))
|
|
437
|
+
continue;
|
|
438
|
+
const anchorId = element.dataset.sourceAnchorId;
|
|
439
|
+
if (!anchorId || !element.dataset.pageFragmentId
|
|
440
|
+
|| !Number.isInteger(parseInt(element.dataset.fragmentIndex || "", 10))) {
|
|
441
|
+
throw new Error(`page ${page.pageNumber} contains an unqualified source anchor`);
|
|
442
|
+
}
|
|
443
|
+
const rect = element.getBoundingClientRect();
|
|
444
|
+
const style = this.view.getComputedStyle(element);
|
|
445
|
+
const deliberatelyHidden = style.display === "none" || style.visibility === "hidden";
|
|
446
|
+
if (deliberatelyHidden)
|
|
447
|
+
continue;
|
|
448
|
+
requiredAnchorIds.add(anchorId);
|
|
449
|
+
const visibleRect = this.intersectWithClippingAncestors(element, page.element, pageRect, rect);
|
|
450
|
+
const left = visibleRect.left;
|
|
451
|
+
const top = visibleRect.top;
|
|
452
|
+
const right = visibleRect.right;
|
|
453
|
+
const bottom = visibleRect.bottom;
|
|
454
|
+
if (rect.width <= 0 || rect.height <= 0 || right <= left || bottom <= top) {
|
|
455
|
+
// A continued note/story clone can contain children clipped off this page which become
|
|
456
|
+
// measurable on its next clone. Enforce completeness once every page has been inspected.
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
measuredAnchorIds.add(anchorId);
|
|
460
|
+
// Clipped descendants in repeated note/story clones are deliberately omitted from the
|
|
461
|
+
// portable map. Re-number only the visible fragments so the emitted contract remains
|
|
462
|
+
// contiguous even when an earlier DOM clone carried no visible geometry on its page.
|
|
463
|
+
const fragmentIndex = emittedFragmentCounts.get(anchorId) ?? 0;
|
|
464
|
+
emittedFragmentCounts.set(anchorId, fragmentIndex + 1);
|
|
465
|
+
const fragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
|
|
466
|
+
if (element.dataset.fragmentIndex !== String(fragmentIndex)
|
|
467
|
+
|| element.dataset.pageFragmentId !== fragmentId
|
|
468
|
+
|| element.dataset.pageNumber !== String(page.pageNumber)) {
|
|
469
|
+
throw new Error(`page ${page.pageNumber} fragment identity changed after final-tree normalization`);
|
|
470
|
+
}
|
|
471
|
+
fragments.push({
|
|
472
|
+
fragmentId,
|
|
473
|
+
anchorId,
|
|
474
|
+
fragmentIndex,
|
|
475
|
+
pageNumber: page.pageNumber,
|
|
476
|
+
geometry: {
|
|
477
|
+
x: (left - pageRect.left) * pointPerRenderedX,
|
|
478
|
+
y: (top - pageRect.top) * pointPerRenderedY,
|
|
479
|
+
width: (right - left) * pointPerRenderedX,
|
|
480
|
+
height: (bottom - top) * pointPerRenderedY,
|
|
481
|
+
},
|
|
482
|
+
story: this.storyForCanonicalAnchor(anchorId),
|
|
483
|
+
inTableCell: element.matches("td,th") || element.closest("td,th") !== null,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const missingAnchor = Array.from(requiredAnchorIds).find((id) => !measuredAnchorIds.has(id));
|
|
488
|
+
if (missingAnchor) {
|
|
489
|
+
throw new Error(`source anchor ${missingAnchor} has no measurable fragment in the paginated layout`);
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
schemaVersion: 1,
|
|
493
|
+
mode: "paginated",
|
|
494
|
+
availability: "available",
|
|
495
|
+
documentVersion,
|
|
496
|
+
rendererFingerprint,
|
|
497
|
+
pages,
|
|
498
|
+
fragments,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
normalizeVisiblePageFragments(pages) {
|
|
502
|
+
const emittedFragmentCounts = new Map();
|
|
503
|
+
for (const page of pages) {
|
|
504
|
+
this.checkpoint();
|
|
505
|
+
const pageRect = page.element.getBoundingClientRect();
|
|
506
|
+
for (const element of Array.from(page.element.querySelectorAll("[data-source-anchor-id]"))) {
|
|
507
|
+
this.checkpoint();
|
|
508
|
+
if (this.isDeliberatelyUnrenderedSource(element, page.element))
|
|
509
|
+
continue;
|
|
510
|
+
const anchorId = element.dataset.sourceAnchorId;
|
|
511
|
+
if (!anchorId)
|
|
512
|
+
continue;
|
|
513
|
+
const style = this.view.getComputedStyle(element);
|
|
514
|
+
if (style.display === "none" || style.visibility === "hidden")
|
|
515
|
+
continue;
|
|
516
|
+
const rect = element.getBoundingClientRect();
|
|
517
|
+
const visible = this.intersectWithClippingAncestors(element, page.element, pageRect, rect);
|
|
518
|
+
if (rect.width <= 0 || rect.height <= 0
|
|
519
|
+
|| visible.right <= visible.left || visible.bottom <= visible.top)
|
|
520
|
+
continue;
|
|
521
|
+
const fragmentIndex = emittedFragmentCounts.get(anchorId) ?? 0;
|
|
522
|
+
emittedFragmentCounts.set(anchorId, fragmentIndex + 1);
|
|
523
|
+
element.dataset.pageNumber = String(page.pageNumber);
|
|
524
|
+
element.dataset.fragmentIndex = String(fragmentIndex);
|
|
525
|
+
element.dataset.pageFragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Intersect an element with every ancestor that establishes an overflow clip before the page
|
|
531
|
+
* root. getBoundingClientRect() reports layout outside those clips, which is not rendered and
|
|
532
|
+
* therefore must not satisfy PageMap completeness or inflate portable geometry.
|
|
533
|
+
*/
|
|
534
|
+
intersectWithClippingAncestors(element, page, pageRect, rect) {
|
|
535
|
+
let left = Math.max(rect.left, pageRect.left);
|
|
536
|
+
let top = Math.max(rect.top, pageRect.top);
|
|
537
|
+
let right = Math.min(rect.right, pageRect.right);
|
|
538
|
+
let bottom = Math.min(rect.bottom, pageRect.bottom);
|
|
539
|
+
const clips = (value) => value === "hidden" || value === "clip" || value === "scroll" || value === "auto";
|
|
540
|
+
for (let ancestor = element.parentElement; ancestor && ancestor !== page; ancestor = ancestor.parentElement) {
|
|
541
|
+
const style = this.view.getComputedStyle(ancestor);
|
|
542
|
+
const clipsX = clips(style.overflowX);
|
|
543
|
+
const clipsY = clips(style.overflowY);
|
|
544
|
+
if (!clipsX && !clipsY)
|
|
545
|
+
continue;
|
|
546
|
+
const ancestorRect = ancestor.getBoundingClientRect();
|
|
547
|
+
if (clipsX) {
|
|
548
|
+
left = Math.max(left, ancestorRect.left);
|
|
549
|
+
right = Math.min(right, ancestorRect.right);
|
|
550
|
+
}
|
|
551
|
+
if (clipsY) {
|
|
552
|
+
top = Math.max(top, ancestorRect.top);
|
|
553
|
+
bottom = Math.min(bottom, ancestorRect.bottom);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { left, top, right, bottom };
|
|
557
|
+
}
|
|
558
|
+
storyForCanonicalAnchor(anchorId) {
|
|
559
|
+
const first = anchorId.indexOf(":");
|
|
560
|
+
const second = first < 0 ? -1 : anchorId.indexOf(":", first + 1);
|
|
561
|
+
const scope = first >= 0 && second > first ? anchorId.slice(first + 1, second) : "body";
|
|
562
|
+
if (scope.startsWith("hdr"))
|
|
563
|
+
return "header";
|
|
564
|
+
if (scope.startsWith("ftr"))
|
|
565
|
+
return "footer";
|
|
566
|
+
if (scope === "fn")
|
|
567
|
+
return "footnote";
|
|
568
|
+
if (scope === "en")
|
|
569
|
+
return "endnote";
|
|
570
|
+
if (scope === "cmt")
|
|
571
|
+
return "comment";
|
|
572
|
+
return "body";
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Add canonical IDs from an addressable source subtree to the pre-pagination inventory.
|
|
576
|
+
* Registry wrappers are excluded when scanning staging because selectable registry contents are
|
|
577
|
+
* inventoried separately. Producers may explicitly mark content that has no visual substrate
|
|
578
|
+
* with `data-page-map-exclude="true"`; native hidden semantics carry the same signal.
|
|
579
|
+
*/
|
|
580
|
+
collectExpectedSourceAnchors(source, destination, excludeRegistries = false) {
|
|
581
|
+
const candidates = source.matches("[data-source-anchor-id]") ? [source] : [];
|
|
582
|
+
candidates.push(...Array.from(source.querySelectorAll("[data-source-anchor-id]")));
|
|
583
|
+
for (const element of candidates) {
|
|
584
|
+
if (excludeRegistries && element.closest("#pagination-hf-registry, #pagination-footnote-registry, #pagination-comment-margin-registry")) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (this.isZeroHeightExplicitBreakCarrier(element)) {
|
|
588
|
+
// Flow consumes the following break marker and clones this otherwise-empty paragraph.
|
|
589
|
+
// Persist the exclusion so that the clone cannot reintroduce the anchor during PageMap
|
|
590
|
+
// measurement after the marker itself has disappeared.
|
|
591
|
+
element.dataset.pageMapExclude = "true";
|
|
592
|
+
}
|
|
593
|
+
if (this.isDeliberatelyUnrenderedSource(element, source))
|
|
594
|
+
continue;
|
|
595
|
+
const anchorId = element.dataset.sourceAnchorId;
|
|
596
|
+
if (anchorId)
|
|
597
|
+
destination.add(anchorId);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
isZeroHeightExplicitBreakCarrier(element) {
|
|
601
|
+
const next = element.nextElementSibling;
|
|
602
|
+
const bounds = element.getBoundingClientRect();
|
|
603
|
+
return element.hasAttribute("data-source-anchor-id")
|
|
604
|
+
&& (element.textContent ?? "").replace(/\u00a0/g, "").trim() === ""
|
|
605
|
+
// Exclude only the converter's zero-height carrier. A blank paragraph with height from
|
|
606
|
+
// padding, borders, a background, or authored sizing still has a visible page substrate and
|
|
607
|
+
// must remain addressable under the authoritative PageMap contract.
|
|
608
|
+
&& bounds.height <= 0.01
|
|
609
|
+
&& !element.querySelector("[data-source-anchor-id]")
|
|
610
|
+
&& !element.querySelector("img,svg,canvas,table,hr,input,textarea,select")
|
|
611
|
+
&& (next?.dataset.pageBreak === "true"
|
|
612
|
+
|| next?.classList.contains(`${this.cssPrefix}break`) === true);
|
|
613
|
+
}
|
|
614
|
+
isDeliberatelyUnrenderedSource(element, sourceRoot) {
|
|
615
|
+
for (let current = element; current; current = current.parentElement) {
|
|
616
|
+
const zeroHeightExplicitBreakCarrier = current === element
|
|
617
|
+
&& this.isZeroHeightExplicitBreakCarrier(current);
|
|
618
|
+
if (current.dataset.pageMapExclude === "true"
|
|
619
|
+
// An explicit page-break marker controls flow but has no painted substrate. The converter
|
|
620
|
+
// intentionally emits it as an empty div, so requiring point geometry for its source
|
|
621
|
+
// identity would make every otherwise-valid document with w:br[type=page] fail closed.
|
|
622
|
+
|| current.dataset.pageBreak === "true"
|
|
623
|
+
|| current.classList.contains(`${this.cssPrefix}break`)
|
|
624
|
+
|| zeroHeightExplicitBreakCarrier
|
|
625
|
+
|| current.hidden
|
|
626
|
+
|| current.getAttribute("aria-hidden") === "true"
|
|
627
|
+
|| current.style.display === "none"
|
|
628
|
+
|| current.style.visibility === "hidden") {
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
if (current === sourceRoot)
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Keep exactly one active bare-Unid editor anchor per source block. Presentation clones use
|
|
638
|
+
* canonical source identity plus page/fragment qualification instead.
|
|
639
|
+
*/
|
|
640
|
+
qualifyPageFragments(pages) {
|
|
641
|
+
const fragmentCounts = new Map();
|
|
642
|
+
const activeCanonicalIds = new Set();
|
|
643
|
+
const activeBareAnchorIds = new Set();
|
|
644
|
+
const makeInactive = (element) => {
|
|
645
|
+
element.removeAttribute("data-anchor");
|
|
646
|
+
element.removeAttribute("data-committed-text");
|
|
647
|
+
if (element.hasAttribute("contenteditable"))
|
|
648
|
+
element.setAttribute("contenteditable", "false");
|
|
649
|
+
};
|
|
650
|
+
for (const page of pages) {
|
|
651
|
+
const nodes = page.element.querySelectorAll("[data-source-anchor-id]");
|
|
652
|
+
for (const element of Array.from(nodes)) {
|
|
653
|
+
const anchorId = element.dataset.sourceAnchorId;
|
|
654
|
+
if (!anchorId)
|
|
655
|
+
continue;
|
|
656
|
+
const fragmentIndex = fragmentCounts.get(anchorId) ?? 0;
|
|
657
|
+
fragmentCounts.set(anchorId, fragmentIndex + 1);
|
|
658
|
+
element.dataset.pageNumber = String(page.pageNumber);
|
|
659
|
+
element.dataset.fragmentIndex = String(fragmentIndex);
|
|
660
|
+
element.dataset.pageFragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
|
|
661
|
+
const story = this.storyForCanonicalAnchor(anchorId);
|
|
662
|
+
const mayOwnActiveEditorAnchor = story === "body" || story === "comment" || story === "footnote" || story === "endnote";
|
|
663
|
+
if (element.hasAttribute("data-anchor")) {
|
|
664
|
+
const bareAnchorId = element.dataset.anchor;
|
|
665
|
+
if (mayOwnActiveEditorAnchor
|
|
666
|
+
&& !activeCanonicalIds.has(anchorId)
|
|
667
|
+
&& !activeBareAnchorIds.has(bareAnchorId)) {
|
|
668
|
+
activeCanonicalIds.add(anchorId);
|
|
669
|
+
activeBareAnchorIds.add(bareAnchorId);
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
makeInactive(element);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Body/comment page nodes are the editable copies. A repeated header/footer registry entry can
|
|
678
|
+
// also render the same source story once per section/variant, so retain at most one active
|
|
679
|
+
// staging node per canonical source and make every presentation duplicate inert.
|
|
680
|
+
const activeStagingCanonicalIds = new Set();
|
|
681
|
+
for (const element of Array.from(this.stagingElement.querySelectorAll("[data-source-anchor-id][data-anchor]"))) {
|
|
682
|
+
const anchorId = element.dataset.sourceAnchorId;
|
|
683
|
+
if (!anchorId)
|
|
684
|
+
continue;
|
|
685
|
+
const bareAnchorId = element.dataset.anchor;
|
|
686
|
+
if (activeCanonicalIds.has(anchorId)
|
|
687
|
+
|| activeStagingCanonicalIds.has(anchorId)
|
|
688
|
+
|| activeBareAnchorIds.has(bareAnchorId)) {
|
|
689
|
+
makeInactive(element);
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
activeStagingCanonicalIds.add(anchorId);
|
|
693
|
+
activeBareAnchorIds.add(bareAnchorId);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Page flow clones source blocks while the hidden staging tree stays in the document. Any HTML
|
|
699
|
+
* fragment target copied into a visible page would therefore resolve to its earlier hidden
|
|
700
|
+
* source. Transfer target ownership to the page presentation after flow is complete; registry
|
|
701
|
+
* and wrapper IDs that have no visible counterpart remain available to pagination internals.
|
|
702
|
+
*/
|
|
703
|
+
transferVisibleFragmentTargets() {
|
|
704
|
+
const visibleIds = new Set(Array.from(this.containerElement.querySelectorAll("[id]")).map((element) => element.id).filter(Boolean));
|
|
705
|
+
if (visibleIds.size === 0)
|
|
706
|
+
return;
|
|
707
|
+
for (const source of Array.from(this.stagingElement.querySelectorAll("[id]"))) {
|
|
708
|
+
if (visibleIds.has(source.id))
|
|
709
|
+
source.removeAttribute("id");
|
|
710
|
+
}
|
|
88
711
|
}
|
|
89
712
|
/** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */
|
|
90
713
|
parsePageNumbering(sections) {
|
|
@@ -127,11 +750,8 @@ export class PaginationEngine {
|
|
|
127
750
|
continue;
|
|
128
751
|
const sectionIndex = parseInt(box.dataset.sectionIndex || "0", 10);
|
|
129
752
|
const pageNumber = parseInt(box.dataset.pageNumber || "1", 10);
|
|
130
|
-
const pageInSection = parseInt(box.dataset.pageInSection || "1", 10);
|
|
131
753
|
const numbering = this.pageNumbering.get(sectionIndex) ?? {};
|
|
132
|
-
|
|
133
|
-
// the document-wide running number.
|
|
134
|
-
const displayed = numbering.start !== undefined ? numbering.start + pageInSection - 1 : pageNumber;
|
|
754
|
+
const displayed = parseInt(box.dataset.displayedPageNumber || String(pageNumber), 10);
|
|
135
755
|
for (const marker of Array.from(markers)) {
|
|
136
756
|
const kind = marker.dataset.field;
|
|
137
757
|
if (kind !== "PAGE" && kind !== "NUMPAGES")
|
|
@@ -144,22 +764,35 @@ export class PaginationEngine {
|
|
|
144
764
|
/**
|
|
145
765
|
* Measures all content blocks in a section.
|
|
146
766
|
*/
|
|
147
|
-
measureBlocks(section, dims) {
|
|
767
|
+
measureBlocks(section, dims, sectionIndex) {
|
|
148
768
|
const blocks = [];
|
|
149
769
|
// Get direct children (paragraphs, tables, divs, etc.)
|
|
150
770
|
const children = Array.from(section.children);
|
|
151
771
|
for (const child of children) {
|
|
772
|
+
this.checkpoint();
|
|
152
773
|
// Skip section dividers that are just wrappers
|
|
153
774
|
if (child.dataset.sectionIndex !== undefined) {
|
|
154
775
|
// Recursively get blocks from nested sections
|
|
155
|
-
const
|
|
776
|
+
const nestedSectionIndex = parseInt(child.dataset.sectionIndex || String(sectionIndex), 10);
|
|
777
|
+
const nestedBlocks = this.measureBlocks(child, dims, nestedSectionIndex);
|
|
156
778
|
blocks.push(...nestedBlocks);
|
|
157
779
|
continue;
|
|
158
780
|
}
|
|
781
|
+
// Converter-shaped endnotes are a safe nested block structure whose outer
|
|
782
|
+
// section/list wrappers must not make the complete endnote collection one
|
|
783
|
+
// indivisible page block. Flatten only the exact shape we understand; any
|
|
784
|
+
// richer author HTML retains the conservative whole-block fallback below.
|
|
785
|
+
if (child.matches("section.endnotes")) {
|
|
786
|
+
const endnoteBlocks = this.measureSafeEndnoteBlocks(child, dims, sectionIndex);
|
|
787
|
+
if (endnoteBlocks) {
|
|
788
|
+
blocks.push(...endnoteBlocks);
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
159
792
|
// Measure height and margins separately for proper margin collapsing calculation
|
|
160
793
|
// getBoundingClientRect() returns content+padding+border, not margins
|
|
161
794
|
const rect = child.getBoundingClientRect();
|
|
162
|
-
const style =
|
|
795
|
+
const style = this.view.getComputedStyle(child);
|
|
163
796
|
const marginTopPx = parseFloat(style.marginTop) || 0;
|
|
164
797
|
const marginBottomPx = parseFloat(style.marginBottom) || 0;
|
|
165
798
|
const heightPt = pxToPt(rect.height);
|
|
@@ -169,6 +802,7 @@ export class PaginationEngine {
|
|
|
169
802
|
child.classList.contains(`${this.cssPrefix}break`);
|
|
170
803
|
blocks.push({
|
|
171
804
|
element: child,
|
|
805
|
+
sectionIndex,
|
|
172
806
|
heightPt,
|
|
173
807
|
marginTopPt,
|
|
174
808
|
marginBottomPt,
|
|
@@ -176,7 +810,168 @@ export class PaginationEngine {
|
|
|
176
810
|
keepLines: child.dataset.keepLines === "true",
|
|
177
811
|
pageBreakBefore: child.dataset.pageBreakBefore === "true",
|
|
178
812
|
isPageBreak,
|
|
813
|
+
isWordParagraph: this.isWordParagraphElement(child),
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return blocks;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Flatten the converter's `section.endnotes > ol > li > p` presentation into
|
|
820
|
+
* ordinary paragraph blocks. This preserves paragraph formatting and canonical
|
|
821
|
+
* p:en/en:en identities while allowing the existing paragraph fragmenter to
|
|
822
|
+
* split a long endnote across page boundaries.
|
|
823
|
+
*/
|
|
824
|
+
measureSafeEndnoteBlocks(section, dims, sectionIndex) {
|
|
825
|
+
var _a, _b, _c;
|
|
826
|
+
const sectionChildren = Array.from(section.children);
|
|
827
|
+
const list = sectionChildren.find((child) => child.tagName === "OL");
|
|
828
|
+
if (!list
|
|
829
|
+
|| sectionChildren.some((child) => child.tagName !== "HR" && child !== list)
|
|
830
|
+
|| Array.from(list.children).some((child) => child.tagName !== "LI")) {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
const items = Array.from(list.children);
|
|
834
|
+
if (items.length === 0 || items.some((item) => item.children.length === 0
|
|
835
|
+
|| Array.from(item.children).some((child) => child.tagName !== "P"))) {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
const blocks = [];
|
|
839
|
+
const sectionStyle = this.view.getComputedStyle(section);
|
|
840
|
+
for (const rule of sectionChildren.filter((child) => child.tagName === "HR")) {
|
|
841
|
+
this.checkpoint();
|
|
842
|
+
const clonedRule = rule.cloneNode(true);
|
|
843
|
+
if (blocks.length === 0)
|
|
844
|
+
clonedRule.style.marginTop = sectionStyle.marginTop;
|
|
845
|
+
blocks.push(this.measureElement(clonedRule, dims, sectionIndex));
|
|
846
|
+
}
|
|
847
|
+
const listStyle = this.view.getComputedStyle(list);
|
|
848
|
+
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
|
849
|
+
this.checkpoint();
|
|
850
|
+
const item = items[itemIndex];
|
|
851
|
+
const ownerAnchorId = item.dataset.sourceAnchorId;
|
|
852
|
+
const paragraphs = Array.from(item.children);
|
|
853
|
+
for (let paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex++) {
|
|
854
|
+
this.checkpoint();
|
|
855
|
+
const paragraph = paragraphs[paragraphIndex];
|
|
856
|
+
// The flattened clone no longer has the section/ol/li ancestors that supplied the
|
|
857
|
+
// source's computed layout. Validate while the real paragraph is still attached; the
|
|
858
|
+
// marker below records that completed check for canFragmentParagraph(), whose detached
|
|
859
|
+
// clone cannot obtain meaningful computed styles. A richer custom endnote falls back to
|
|
860
|
+
// the established indivisible section path instead of being range-split incorrectly.
|
|
861
|
+
if (!this.hasRangeFragmentSafeLayout(paragraph)) {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
const clone = paragraph.cloneNode(true);
|
|
865
|
+
clone.dataset.paginationSafeEndnote = "true";
|
|
866
|
+
(_a = clone.style).fontSize || (_a.fontSize = sectionStyle.fontSize);
|
|
867
|
+
(_b = clone.style).lineHeight || (_b.lineHeight = sectionStyle.lineHeight);
|
|
868
|
+
(_c = clone.style).paddingLeft || (_c.paddingLeft = listStyle.paddingLeft);
|
|
869
|
+
// Older/custom producers may put the endnote identity only on the li.
|
|
870
|
+
// Mirror it into the visible paragraph without displacing the paragraph's
|
|
871
|
+
// own identity, matching current converter output.
|
|
872
|
+
if (ownerAnchorId && !Array.from(clone.querySelectorAll("[data-source-anchor-id]")).some((node) => node.dataset.sourceAnchorId === ownerAnchorId)) {
|
|
873
|
+
const owner = this.document.createElement("span");
|
|
874
|
+
owner.dataset.sourceAnchorId = ownerAnchorId;
|
|
875
|
+
while (clone.firstChild)
|
|
876
|
+
owner.appendChild(clone.firstChild);
|
|
877
|
+
clone.appendChild(owner);
|
|
878
|
+
}
|
|
879
|
+
if (paragraphIndex === 0) {
|
|
880
|
+
// Flattening must preserve both ends of the converter's endnote link and the list's
|
|
881
|
+
// numbering format. The outer id survives only on the leading range fragment; normal
|
|
882
|
+
// continuation cleanup removes it from every later fragment.
|
|
883
|
+
const itemId = item.id;
|
|
884
|
+
if (itemId) {
|
|
885
|
+
if (clone.id && clone.id !== itemId)
|
|
886
|
+
return null;
|
|
887
|
+
clone.id = itemId;
|
|
888
|
+
}
|
|
889
|
+
const value = parseInt(item.getAttribute("value") || String(itemIndex + 1), 10);
|
|
890
|
+
const marker = this.formatOrderedListMarker(Number.isFinite(value) ? value : itemIndex + 1, listStyle.listStyleType);
|
|
891
|
+
clone.insertBefore(this.document.createTextNode(`${marker}. `), clone.firstChild);
|
|
892
|
+
}
|
|
893
|
+
blocks.push(this.measureElement(clone, dims, sectionIndex));
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return blocks;
|
|
897
|
+
}
|
|
898
|
+
/** Render the CSS ordered-list formats emitted by the converter after an endnote is flattened. */
|
|
899
|
+
formatOrderedListMarker(value, listStyleType) {
|
|
900
|
+
const format = (() => {
|
|
901
|
+
switch (listStyleType) {
|
|
902
|
+
case "lower-roman": return "lowerRoman";
|
|
903
|
+
case "upper-roman": return "upperRoman";
|
|
904
|
+
case "lower-alpha":
|
|
905
|
+
case "lower-latin": return "lowerLetter";
|
|
906
|
+
case "upper-alpha":
|
|
907
|
+
case "upper-latin": return "upperLetter";
|
|
908
|
+
default: return "decimal";
|
|
909
|
+
}
|
|
910
|
+
})();
|
|
911
|
+
return formatPageNumber(value, format);
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Flows a multi-column (`w:cols`) section's children into CSS-multicol container
|
|
915
|
+
* blocks. Word lays such a section out as N columns inside the same body extent;
|
|
916
|
+
* a balanced `column-count` container reproduces that geometry, and the paginator
|
|
917
|
+
* then places each container as one ordinary measured block. A container grows
|
|
918
|
+
* greedily until its balanced height would exceed the smallest page body available
|
|
919
|
+
* to the section, so a long columned section still splits across pages at block
|
|
920
|
+
* boundaries. Each child lands in exactly one container, so anchors never
|
|
921
|
+
* duplicate. An explicit page break child passes through as its own block, which
|
|
922
|
+
* ends the current container and lets the normal flow logic turn the page.
|
|
923
|
+
*/
|
|
924
|
+
buildColumnBlocks(section, dims, sectionIndex, columnCount, columnGapPt) {
|
|
925
|
+
const children = Array.from(section.children);
|
|
926
|
+
const blocks = [];
|
|
927
|
+
const maxFragmentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
|
|
928
|
+
const isBreak = (child) => child.dataset.pageBreak === "true" ||
|
|
929
|
+
child.classList.contains(`${this.cssPrefix}break`);
|
|
930
|
+
const makeContainer = (slice) => {
|
|
931
|
+
const container = this.document.createElement("div");
|
|
932
|
+
container.style.columnCount = String(columnCount);
|
|
933
|
+
container.style.columnGap = `${columnGapPt}pt`;
|
|
934
|
+
for (const child of slice) {
|
|
935
|
+
container.appendChild(child.cloneNode(true));
|
|
936
|
+
}
|
|
937
|
+
return container;
|
|
938
|
+
};
|
|
939
|
+
let start = 0;
|
|
940
|
+
while (start < children.length) {
|
|
941
|
+
this.checkpoint();
|
|
942
|
+
if (isBreak(children[start])) {
|
|
943
|
+
blocks.push(this.measureElement(children[start], dims, sectionIndex));
|
|
944
|
+
start++;
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
// Grow the container one child at a time. Even a single oversized child is
|
|
948
|
+
// emitted alone, preserving the established oversized-block fallback.
|
|
949
|
+
let end = start + 1;
|
|
950
|
+
let container = makeContainer(children.slice(start, end));
|
|
951
|
+
let measured = this.measureElement(container, dims, sectionIndex);
|
|
952
|
+
while (end < children.length && !isBreak(children[end])) {
|
|
953
|
+
this.checkpoint();
|
|
954
|
+
const candidate = makeContainer(children.slice(start, end + 1));
|
|
955
|
+
const candidateMeasured = this.measureElement(candidate, dims, sectionIndex);
|
|
956
|
+
if (candidateMeasured.heightPt > maxFragmentHeight)
|
|
957
|
+
break;
|
|
958
|
+
container = candidate;
|
|
959
|
+
measured = candidateMeasured;
|
|
960
|
+
end++;
|
|
961
|
+
}
|
|
962
|
+
blocks.push({
|
|
963
|
+
element: container,
|
|
964
|
+
sectionIndex,
|
|
965
|
+
heightPt: measured.heightPt,
|
|
966
|
+
marginTopPt: measured.marginTopPt,
|
|
967
|
+
marginBottomPt: measured.marginBottomPt,
|
|
968
|
+
keepWithNext: false,
|
|
969
|
+
keepLines: false,
|
|
970
|
+
pageBreakBefore: false,
|
|
971
|
+
isPageBreak: false,
|
|
972
|
+
isWordParagraph: false,
|
|
179
973
|
});
|
|
974
|
+
start = end;
|
|
180
975
|
}
|
|
181
976
|
return blocks;
|
|
182
977
|
}
|
|
@@ -185,8 +980,8 @@ export class PaginationEngine {
|
|
|
185
980
|
* This is intentionally DOM-based: table row heights cannot be inferred from individual
|
|
186
981
|
* rows because wrapping and collapsed borders change the height of a fragment.
|
|
187
982
|
*/
|
|
188
|
-
measureElement(element, dims) {
|
|
189
|
-
const measurementHost = document.createElement("div");
|
|
983
|
+
measureElement(element, dims, sectionIndex) {
|
|
984
|
+
const measurementHost = this.document.createElement("div");
|
|
190
985
|
measurementHost.style.position = "absolute";
|
|
191
986
|
measurementHost.style.visibility = "hidden";
|
|
192
987
|
measurementHost.style.left = "-9999px";
|
|
@@ -195,9 +990,10 @@ export class PaginationEngine {
|
|
|
195
990
|
measurementHost.appendChild(measuredElement);
|
|
196
991
|
this.stagingElement.appendChild(measurementHost);
|
|
197
992
|
const rect = measuredElement.getBoundingClientRect();
|
|
198
|
-
const style =
|
|
993
|
+
const style = this.view.getComputedStyle(measuredElement);
|
|
199
994
|
const measured = {
|
|
200
995
|
element,
|
|
996
|
+
sectionIndex,
|
|
201
997
|
heightPt: pxToPt(rect.height),
|
|
202
998
|
marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
|
|
203
999
|
marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
|
|
@@ -206,6 +1002,7 @@ export class PaginationEngine {
|
|
|
206
1002
|
pageBreakBefore: element.dataset.pageBreakBefore === "true",
|
|
207
1003
|
isPageBreak: element.dataset.pageBreak === "true" ||
|
|
208
1004
|
element.classList.contains(`${this.cssPrefix}break`),
|
|
1005
|
+
isWordParagraph: this.isWordParagraphElement(element),
|
|
209
1006
|
};
|
|
210
1007
|
this.stagingElement.removeChild(measurementHost);
|
|
211
1008
|
return measured;
|
|
@@ -238,13 +1035,11 @@ export class PaginationEngine {
|
|
|
238
1035
|
* collapsed-margin rules as normal block placement. The trailing margin is
|
|
239
1036
|
* intentionally excluded, matching the individual block fit check.
|
|
240
1037
|
*/
|
|
241
|
-
measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage) {
|
|
1038
|
+
measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage, pageInSection) {
|
|
242
1039
|
const firstBlock = chain[0];
|
|
243
1040
|
if (!firstBlock)
|
|
244
1041
|
return 0;
|
|
245
|
-
const firstMarginTop = isFirstOnPage
|
|
246
|
-
? firstBlock.marginTopPt
|
|
247
|
-
: Math.max(firstBlock.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
|
|
1042
|
+
const firstMarginTop = this.effectiveBlockMarginTop(firstBlock, previousMarginBottomPt, isFirstOnPage, pageInSection);
|
|
248
1043
|
let bodyHeight = firstMarginTop + firstBlock.heightPt;
|
|
249
1044
|
for (let index = 1; index < chain.length; index++) {
|
|
250
1045
|
const previousBlock = chain[index - 1];
|
|
@@ -253,6 +1048,37 @@ export class PaginationEngine {
|
|
|
253
1048
|
}
|
|
254
1049
|
return bodyHeight;
|
|
255
1050
|
}
|
|
1051
|
+
/** Word paragraphs render as `p`, or as `h1`–`h6` when their style has an outline level. */
|
|
1052
|
+
isWordParagraphElement(element) {
|
|
1053
|
+
return element.tagName === "P" || /^H[1-6]$/.test(element.tagName);
|
|
1054
|
+
}
|
|
1055
|
+
shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection) {
|
|
1056
|
+
return isFirstOnPage && pageInSection > 1 && block.isWordParagraph === true;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Resolves the part of a block's top margin that consumes the current page.
|
|
1060
|
+
*
|
|
1061
|
+
* In Word's native DOCX layout, paragraph space-before is suppressed when a paragraph is the
|
|
1062
|
+
* first body block on a later page of the SAME section. The first page of a document/section is
|
|
1063
|
+
* the exception and keeps its spacing. Tables and other block margins are not paragraph spacing,
|
|
1064
|
+
* so they continue to use the ordinary CSS collapsing rule.
|
|
1065
|
+
*/
|
|
1066
|
+
effectiveBlockMarginTop(block, previousMarginBottomPt, isFirstOnPage, pageInSection) {
|
|
1067
|
+
if (this.shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection)) {
|
|
1068
|
+
return 0;
|
|
1069
|
+
}
|
|
1070
|
+
return isFirstOnPage
|
|
1071
|
+
? block.marginTopPt
|
|
1072
|
+
: Math.max(block.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
|
|
1073
|
+
}
|
|
1074
|
+
/** Clone a source block with the same page-top spacing decision used by the height budget. */
|
|
1075
|
+
cloneBlockForPage(block, isFirstOnPage, pageInSection) {
|
|
1076
|
+
const clone = block.element.cloneNode(true);
|
|
1077
|
+
if (this.shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection)) {
|
|
1078
|
+
clone.style.setProperty("margin-top", "0", "important");
|
|
1079
|
+
}
|
|
1080
|
+
return clone;
|
|
1081
|
+
}
|
|
256
1082
|
/**
|
|
257
1083
|
* Finds footnote references introduced by a sequence of blocks, preserving
|
|
258
1084
|
* document order and excluding references already assigned to the page.
|
|
@@ -261,6 +1087,7 @@ export class PaginationEngine {
|
|
|
261
1087
|
const knownIds = new Set(existingFootnoteIds);
|
|
262
1088
|
const newIds = [];
|
|
263
1089
|
for (const block of blocks) {
|
|
1090
|
+
this.checkpoint();
|
|
264
1091
|
for (const id of this.extractFootnoteRefs(block.element)) {
|
|
265
1092
|
if (!knownIds.has(id)) {
|
|
266
1093
|
knownIds.add(id);
|
|
@@ -276,7 +1103,7 @@ export class PaginationEngine {
|
|
|
276
1103
|
* send it through the oversized-block fallback again.
|
|
277
1104
|
*/
|
|
278
1105
|
smallestEffectiveContentHeight(dims, sectionIndex) {
|
|
279
|
-
return Math.min(this.getPageBands(dims, sectionIndex, 1, 1).bodyHeight, this.getPageBands(dims, sectionIndex, 2,
|
|
1106
|
+
return Math.min(this.getPageBands(dims, sectionIndex, 1, 1).bodyHeight, this.getPageBands(dims, sectionIndex, 2, 2).bodyHeight, this.getPageBands(dims, sectionIndex, 3, 3).bodyHeight);
|
|
280
1107
|
}
|
|
281
1108
|
/**
|
|
282
1109
|
* Builds a clone of a simple table wrapper containing a contiguous run of rows.
|
|
@@ -320,10 +1147,11 @@ export class PaginationEngine {
|
|
|
320
1147
|
block.isPageBreak) {
|
|
321
1148
|
return null;
|
|
322
1149
|
}
|
|
323
|
-
const
|
|
324
|
-
if (!
|
|
1150
|
+
const tableElement = wrapper.firstElementChild;
|
|
1151
|
+
if (!tableElement || tableElement.localName !== "table") {
|
|
325
1152
|
return null;
|
|
326
1153
|
}
|
|
1154
|
+
const table = tableElement;
|
|
327
1155
|
const body = table.tBodies.length === 1 ? table.tBodies[0] : null;
|
|
328
1156
|
if (!body ||
|
|
329
1157
|
table.tHead ||
|
|
@@ -346,10 +1174,12 @@ export class PaginationEngine {
|
|
|
346
1174
|
const groups = [];
|
|
347
1175
|
let start = 0;
|
|
348
1176
|
while (start < rows.length) {
|
|
1177
|
+
this.checkpoint();
|
|
349
1178
|
let end = start;
|
|
350
1179
|
while (end < rows.length) {
|
|
1180
|
+
this.checkpoint();
|
|
351
1181
|
const candidate = this.createSimpleTableFragment(wrapper, table, body, rows.slice(start, end + 1), start === 0);
|
|
352
|
-
const measured = this.measureElement(candidate, dims);
|
|
1182
|
+
const measured = this.measureElement(candidate, dims, block.sectionIndex);
|
|
353
1183
|
if (measured.heightPt > maximumFragmentHeight) {
|
|
354
1184
|
break;
|
|
355
1185
|
}
|
|
@@ -380,7 +1210,7 @@ export class PaginationEngine {
|
|
|
380
1210
|
if (!isLast) {
|
|
381
1211
|
fragment.style.setProperty("margin-bottom", "0", "important");
|
|
382
1212
|
}
|
|
383
|
-
const measured = this.measureElement(fragment, dims);
|
|
1213
|
+
const measured = this.measureElement(fragment, dims, block.sectionIndex);
|
|
384
1214
|
if (measured.heightPt + measured.marginTopPt + measured.marginBottomPt >
|
|
385
1215
|
minimumContentHeight) {
|
|
386
1216
|
return null;
|
|
@@ -396,40 +1226,223 @@ export class PaginationEngine {
|
|
|
396
1226
|
return fragments;
|
|
397
1227
|
}
|
|
398
1228
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
1229
|
+
* DOM endpoints that can finish a paragraph fragment. The flattened UTF-16
|
|
1230
|
+
* offsets are checked against the browser's Unicode grapheme segmenter, so a
|
|
1231
|
+
* formatting-run boundary can never bisect a surrogate pair, combining
|
|
1232
|
+
* sequence, or joined emoji. NBSP/word-joiner boundaries remain indivisible.
|
|
1233
|
+
* PAGE/NUMPAGES field results are atomic because splitting their marker would
|
|
1234
|
+
* make later substitution duplicate or replace only half of the field.
|
|
402
1235
|
*/
|
|
403
|
-
paragraphFragmentEndpoints(paragraph) {
|
|
404
|
-
const
|
|
405
|
-
const
|
|
1236
|
+
paragraphFragmentEndpoints(paragraph, emergencyGraphemeBreaks = false) {
|
|
1237
|
+
const textNodes = [];
|
|
1238
|
+
const textStarts = new Map();
|
|
1239
|
+
const lastTextByField = new Map();
|
|
1240
|
+
const walker = this.document.createTreeWalker(paragraph, this.view.NodeFilter.SHOW_TEXT);
|
|
1241
|
+
const flattenedChunks = [];
|
|
1242
|
+
let flattenedLength = 0;
|
|
406
1243
|
let textNode;
|
|
407
1244
|
while ((textNode = walker.nextNode())) {
|
|
408
|
-
|
|
409
|
-
|
|
1245
|
+
this.checkpoint();
|
|
1246
|
+
textStarts.set(textNode, flattenedLength);
|
|
1247
|
+
textNodes.push(textNode);
|
|
1248
|
+
flattenedChunks.push(textNode.data);
|
|
1249
|
+
flattenedLength += textNode.data.length;
|
|
1250
|
+
let field = textNode.parentElement?.closest("[data-field]") ?? null;
|
|
1251
|
+
while (field?.parentElement?.closest("[data-field]")) {
|
|
1252
|
+
field = field.parentElement.closest("[data-field]");
|
|
1253
|
+
}
|
|
1254
|
+
if (field && paragraph.contains(field))
|
|
1255
|
+
lastTextByField.set(field, textNode);
|
|
1256
|
+
}
|
|
1257
|
+
const flattenedText = flattenedChunks.join("");
|
|
1258
|
+
if (!this.hasVisibleText(flattenedText))
|
|
1259
|
+
return [];
|
|
1260
|
+
const invisible = /[\s\u200B-\u200F\uFEFF]/;
|
|
1261
|
+
let firstVisibleOffset = -1;
|
|
1262
|
+
let lastVisibleOffset = -1;
|
|
1263
|
+
for (let index = 0; index < flattenedText.length; index++) {
|
|
1264
|
+
if (index % 4096 === 0)
|
|
1265
|
+
this.checkpoint();
|
|
1266
|
+
if (invisible.test(flattenedText[index]))
|
|
410
1267
|
continue;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
1268
|
+
if (firstVisibleOffset < 0)
|
|
1269
|
+
firstVisibleOffset = index;
|
|
1270
|
+
lastVisibleOffset = index;
|
|
1271
|
+
}
|
|
1272
|
+
const graphemeBoundaries = this.graphemeBoundaryOffsets(flattenedText);
|
|
1273
|
+
const candidates = [];
|
|
1274
|
+
const addCandidate = (candidate) => {
|
|
1275
|
+
const { textOffset } = candidate;
|
|
1276
|
+
if (textOffset <= 0 || textOffset >= flattenedText.length)
|
|
1277
|
+
return;
|
|
1278
|
+
if (textOffset <= firstVisibleOffset || textOffset > lastVisibleOffset)
|
|
1279
|
+
return;
|
|
1280
|
+
if (this.isNonBreakingTextBoundary(flattenedText, textOffset))
|
|
1281
|
+
return;
|
|
1282
|
+
if (!this.isLegalParagraphLineBoundary(paragraph, flattenedText, textOffset))
|
|
1283
|
+
return;
|
|
1284
|
+
if (graphemeBoundaries) {
|
|
1285
|
+
if (!graphemeBoundaries.has(textOffset))
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
else if (!this.isConservativeFallbackTextBoundary(flattenedText, textOffset)) {
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
candidates.push(candidate);
|
|
1292
|
+
};
|
|
1293
|
+
for (const node of textNodes) {
|
|
1294
|
+
this.checkpoint();
|
|
1295
|
+
const start = textStarts.get(node);
|
|
1296
|
+
// Field results and converter list markers are semantic atoms. A field
|
|
1297
|
+
// receives one boundary outside its wrapper below; a list marker stays
|
|
1298
|
+
// with the first real text fragment and is never emitted by itself.
|
|
1299
|
+
const atomic = node.parentElement?.closest("[data-field], [data-list-marker], a[data-comment-id]");
|
|
1300
|
+
if (atomic && paragraph.contains(atomic))
|
|
1301
|
+
continue;
|
|
1302
|
+
// Only CSS-collapsible ASCII whitespace is a wrapping opportunity. JS's
|
|
1303
|
+
// broader `\s` class includes NBSP and other explicitly non-breaking text.
|
|
1304
|
+
const whitespace = /[\u0009-\u000D\u0020]+/g;
|
|
415
1305
|
let match;
|
|
416
|
-
|
|
417
|
-
|
|
1306
|
+
let matchesSinceCheckpoint = 0;
|
|
1307
|
+
while ((match = whitespace.exec(node.data)) !== null) {
|
|
1308
|
+
if (++matchesSinceCheckpoint % 256 === 0)
|
|
1309
|
+
this.checkpoint();
|
|
1310
|
+
const offset = match.index + match[0].length;
|
|
1311
|
+
addCandidate({ node, offset, textOffset: start + offset, priority: 0 });
|
|
1312
|
+
}
|
|
1313
|
+
addCandidate({
|
|
1314
|
+
node,
|
|
1315
|
+
offset: node.data.length,
|
|
1316
|
+
textOffset: start + node.data.length,
|
|
1317
|
+
priority: 0,
|
|
1318
|
+
});
|
|
1319
|
+
if (emergencyGraphemeBreaks) {
|
|
1320
|
+
for (let offset = 1; offset < node.data.length; offset++) {
|
|
1321
|
+
if (offset % 256 === 0)
|
|
1322
|
+
this.checkpoint();
|
|
1323
|
+
addCandidate({
|
|
1324
|
+
node,
|
|
1325
|
+
offset,
|
|
1326
|
+
textOffset: start + offset,
|
|
1327
|
+
priority: -1,
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
418
1330
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1331
|
+
}
|
|
1332
|
+
// A field is splittable only immediately after its complete outer wrapper.
|
|
1333
|
+
// Starting the tail at the parent's child boundary prevents Range from
|
|
1334
|
+
// cloning an empty `[data-field]` shell that substitution could repopulate.
|
|
1335
|
+
for (const field of Array.from(paragraph.querySelectorAll("[data-field]"))) {
|
|
1336
|
+
this.checkpoint();
|
|
1337
|
+
if (field.parentElement?.closest("[data-field]"))
|
|
1338
|
+
continue;
|
|
1339
|
+
const last = lastTextByField.get(field);
|
|
1340
|
+
const parent = field.parentNode;
|
|
1341
|
+
if (!last || !parent)
|
|
1342
|
+
continue;
|
|
1343
|
+
const childIndex = Array.prototype.indexOf.call(parent.childNodes, field);
|
|
1344
|
+
if (childIndex < 0)
|
|
1345
|
+
continue;
|
|
1346
|
+
addCandidate({
|
|
1347
|
+
node: parent,
|
|
1348
|
+
offset: childIndex + 1,
|
|
1349
|
+
textOffset: textStarts.get(last) + last.data.length,
|
|
1350
|
+
priority: 1,
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
// Several DOM boundaries can represent the same flattened offset. Prefer
|
|
1354
|
+
// the outer atomic boundary, then keep one stable document-order candidate.
|
|
1355
|
+
candidates.sort((left, right) => left.textOffset - right.textOffset || right.priority - left.priority);
|
|
1356
|
+
const endpoints = [];
|
|
1357
|
+
for (const candidate of candidates) {
|
|
1358
|
+
if (endpoints[endpoints.length - 1]?.textOffset !== candidate.textOffset) {
|
|
1359
|
+
endpoints.push(candidate);
|
|
422
1360
|
}
|
|
423
1361
|
}
|
|
424
1362
|
return endpoints;
|
|
425
1363
|
}
|
|
1364
|
+
/** Browser-native UAX #29 boundaries; null keeps older runtimes conservative. */
|
|
1365
|
+
graphemeBoundaryOffsets(text) {
|
|
1366
|
+
const SegmenterConstructor = this.view.Intl.Segmenter;
|
|
1367
|
+
if (!SegmenterConstructor)
|
|
1368
|
+
return null;
|
|
1369
|
+
const boundaries = new Set([0, text.length]);
|
|
1370
|
+
let segmentCount = 0;
|
|
1371
|
+
for (const part of new SegmenterConstructor("en", { granularity: "grapheme" }).segment(text)) {
|
|
1372
|
+
if (++segmentCount % 256 === 0)
|
|
1373
|
+
this.checkpoint();
|
|
1374
|
+
boundaries.add(part.index);
|
|
1375
|
+
boundaries.add(part.index + part.segment.length);
|
|
1376
|
+
}
|
|
1377
|
+
return boundaries;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Conservative UAX #14/CSS wrapping opportunities used for synthetic page
|
|
1381
|
+
* boundaries. In particular, a formatting-run boundary is not itself a word
|
|
1382
|
+
* boundary, and Japanese opening/closing punctuation stays with its pair.
|
|
1383
|
+
* Arbitrary grapheme breaks are admitted only when the paragraph's CSS asks
|
|
1384
|
+
* the browser to wrap anywhere.
|
|
1385
|
+
*/
|
|
1386
|
+
isLegalParagraphLineBoundary(paragraph, text, offset) {
|
|
1387
|
+
const lastCodeUnit = text.charCodeAt(offset - 1);
|
|
1388
|
+
const beforeOffset = lastCodeUnit >= 0xDC00 && lastCodeUnit <= 0xDFFF
|
|
1389
|
+
? Math.max(0, offset - 2)
|
|
1390
|
+
: offset - 1;
|
|
1391
|
+
const beforeCodePoint = text.codePointAt(beforeOffset);
|
|
1392
|
+
const afterCodePoint = text.codePointAt(offset);
|
|
1393
|
+
const before = beforeCodePoint === undefined ? "" : String.fromCodePoint(beforeCodePoint);
|
|
1394
|
+
const after = afterCodePoint === undefined ? "" : String.fromCodePoint(afterCodePoint);
|
|
1395
|
+
if (/^[\u0009-\u000D\u0020]$/.test(before)
|
|
1396
|
+
|| /^[\u0009-\u000D\u0020]$/.test(after))
|
|
1397
|
+
return true;
|
|
1398
|
+
if (/[-\u00AD\u2010]$/u.test(before))
|
|
1399
|
+
return true;
|
|
1400
|
+
const style = this.view.getComputedStyle(paragraph);
|
|
1401
|
+
if (style.wordBreak === "break-all"
|
|
1402
|
+
|| style.overflowWrap === "anywhere"
|
|
1403
|
+
|| style.overflowWrap === "break-word")
|
|
1404
|
+
return true;
|
|
1405
|
+
const eastAsian = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
1406
|
+
if (!eastAsian.test(before) && !eastAsian.test(after))
|
|
1407
|
+
return false;
|
|
1408
|
+
const opening = "([{<\u2018\u201C\u3008\u300A\u300C\u300E\u3010\u3014\u3016\u3018\u301A\uFF08\uFF3B\uFF5B";
|
|
1409
|
+
const closing = ")]}>\u2019\u201D\u3001\u3002\u3009\u300B\u300D\u300F\u3011\u3015\u3017\u3019\u301B\uFF01\uFF05\uFF09\uFF0C\uFF0E\uFF1A\uFF1B\uFF1F\uFF3D\uFF5D";
|
|
1410
|
+
return !opening.includes(before) && !closing.includes(after);
|
|
1411
|
+
}
|
|
1412
|
+
/** Never fragment across characters whose line-break meaning is explicitly non-breaking. */
|
|
1413
|
+
isNonBreakingTextBoundary(text, offset) {
|
|
1414
|
+
const codePointBefore = (index) => {
|
|
1415
|
+
if (index <= 0)
|
|
1416
|
+
return undefined;
|
|
1417
|
+
const last = text.charCodeAt(index - 1);
|
|
1418
|
+
const start = last >= 0xDC00 && last <= 0xDFFF ? index - 2 : index - 1;
|
|
1419
|
+
return text.codePointAt(Math.max(0, start));
|
|
1420
|
+
};
|
|
1421
|
+
const nonBreaking = new Set([
|
|
1422
|
+
0x00A0, 0x200E, 0x200F, 0x2011, 0x202A, 0x202B, 0x202C,
|
|
1423
|
+
0x202D, 0x202E, 0x202F, 0x2060, 0x2066, 0x2067, 0x2068,
|
|
1424
|
+
0x2069, 0xFEFF,
|
|
1425
|
+
]);
|
|
1426
|
+
return nonBreaking.has(codePointBefore(offset) ?? -1)
|
|
1427
|
+
|| nonBreaking.has(text.codePointAt(offset) ?? -1);
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Without Intl.Segmenter, admit only an all-ASCII boundary. This still
|
|
1431
|
+
* fragments ordinary prose while refusing to guess about Unicode clusters.
|
|
1432
|
+
*/
|
|
1433
|
+
isConservativeFallbackTextBoundary(text, offset) {
|
|
1434
|
+
return text.charCodeAt(offset - 1) <= 0x7F && text.charCodeAt(offset) <= 0x7F;
|
|
1435
|
+
}
|
|
426
1436
|
/**
|
|
427
1437
|
* Whether a range contains visible text after ignoring bidi/zero-width marks.
|
|
428
1438
|
* Paragraph fragmentation deliberately excludes non-textual descendants, so
|
|
429
1439
|
* this is enough to reject empty head or tail fragments.
|
|
430
1440
|
*/
|
|
431
1441
|
hasVisibleFragmentText(fragment) {
|
|
432
|
-
return (fragment.textContent || "")
|
|
1442
|
+
return this.hasVisibleText(fragment.textContent || "");
|
|
1443
|
+
}
|
|
1444
|
+
hasVisibleText(text) {
|
|
1445
|
+
return text
|
|
433
1446
|
.replace(/[\u200B-\u200F\uFEFF]/g, "")
|
|
434
1447
|
.trim()
|
|
435
1448
|
.length > 0;
|
|
@@ -441,7 +1454,7 @@ export class PaginationEngine {
|
|
|
441
1454
|
* fallback instead of risking broken content or duplicate anchors.
|
|
442
1455
|
*/
|
|
443
1456
|
canFragmentParagraph(block) {
|
|
444
|
-
if (!this.fragmentParagraphs
|
|
1457
|
+
if (!this.fragmentParagraphs) {
|
|
445
1458
|
return false;
|
|
446
1459
|
}
|
|
447
1460
|
const paragraph = block.element;
|
|
@@ -453,8 +1466,26 @@ export class PaginationEngine {
|
|
|
453
1466
|
paragraph.hasAttribute("contenteditable")) {
|
|
454
1467
|
return false;
|
|
455
1468
|
}
|
|
456
|
-
|
|
457
|
-
|
|
1469
|
+
return this.canRangeFragmentParagraph(paragraph);
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Shared structural gate for body and note paragraph fragmentation. Footnote
|
|
1473
|
+
* first paragraphs are inline beside their marker, so that one known layout
|
|
1474
|
+
* context may opt into an inline root while retaining every descendant and
|
|
1475
|
+
* break-safety restriction used for body text.
|
|
1476
|
+
*/
|
|
1477
|
+
canRangeFragmentParagraph(paragraph, allowInlineRoot = false) {
|
|
1478
|
+
if (paragraph.tagName !== "P" ||
|
|
1479
|
+
paragraph.dataset.keepWithNext === "true" ||
|
|
1480
|
+
paragraph.dataset.keepLines === "true" ||
|
|
1481
|
+
paragraph.dataset.pageBreakBefore === "true" ||
|
|
1482
|
+
paragraph.dataset.widowControl === "true" ||
|
|
1483
|
+
paragraph.hasAttribute("contenteditable")) {
|
|
1484
|
+
return false;
|
|
1485
|
+
}
|
|
1486
|
+
// Non-textual/out-of-flow descendants still need dedicated fragmenters.
|
|
1487
|
+
// Inline ids/editor anchors are reconciled after Range cloning, and list
|
|
1488
|
+
// markers are kept atomically with the leading text fragment.
|
|
458
1489
|
const unsupportedDescendants = [
|
|
459
1490
|
"br",
|
|
460
1491
|
"img",
|
|
@@ -485,19 +1516,26 @@ export class PaginationEngine {
|
|
|
485
1516
|
"fieldset",
|
|
486
1517
|
"details",
|
|
487
1518
|
"[data-footnote-id]",
|
|
488
|
-
"[data-list-marker]",
|
|
489
|
-
"[data-anchor]",
|
|
490
|
-
"[id]",
|
|
491
1519
|
"[contenteditable]",
|
|
492
1520
|
].join(", ");
|
|
493
1521
|
if (paragraph.querySelector(unsupportedDescendants)) {
|
|
494
1522
|
return false;
|
|
495
1523
|
}
|
|
496
|
-
const
|
|
497
|
-
|
|
1524
|
+
const isValidatedEndnote = paragraph.dataset.paginationSafeEndnote === "true";
|
|
1525
|
+
return isValidatedEndnote || this.hasRangeFragmentSafeLayout(paragraph, allowInlineRoot);
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* A range clone preserves nested inline formatting exactly. Anything that establishes its own
|
|
1529
|
+
* box/layout context is deferred until a future fragmenter can model it accurately. Callers
|
|
1530
|
+
* must invoke this while the paragraph is attached to the styled document.
|
|
1531
|
+
*/
|
|
1532
|
+
hasRangeFragmentSafeLayout(paragraph, allowInlineRoot = false) {
|
|
1533
|
+
const paragraphStyle = this.view.getComputedStyle(paragraph);
|
|
1534
|
+
if ((paragraphStyle.display !== "block" &&
|
|
1535
|
+
!(allowInlineRoot && paragraphStyle.display === "inline")) ||
|
|
498
1536
|
paragraphStyle.position !== "static" ||
|
|
499
1537
|
paragraphStyle.float !== "none" ||
|
|
500
|
-
paragraphStyle.whiteSpace !== "normal" ||
|
|
1538
|
+
(paragraphStyle.whiteSpace !== "normal" && paragraphStyle.whiteSpace !== "pre-wrap") ||
|
|
501
1539
|
paragraphStyle.breakBefore !== "auto" ||
|
|
502
1540
|
paragraphStyle.breakAfter !== "auto" ||
|
|
503
1541
|
paragraphStyle.breakInside === "avoid" ||
|
|
@@ -506,15 +1544,12 @@ export class PaginationEngine {
|
|
|
506
1544
|
paragraphStyle.pageBreakInside === "avoid") {
|
|
507
1545
|
return false;
|
|
508
1546
|
}
|
|
509
|
-
// A range clone preserves nested inline formatting exactly. Anything that
|
|
510
|
-
// establishes its own box/layout context is intentionally deferred until a
|
|
511
|
-
// future fragmenter can model it accurately.
|
|
512
1547
|
for (const descendant of Array.from(paragraph.querySelectorAll("*"))) {
|
|
513
|
-
const style =
|
|
1548
|
+
const style = this.view.getComputedStyle(descendant);
|
|
514
1549
|
if (style.display !== "inline" ||
|
|
515
1550
|
style.position !== "static" ||
|
|
516
1551
|
style.float !== "none" ||
|
|
517
|
-
style.whiteSpace !== "normal") {
|
|
1552
|
+
(style.whiteSpace !== "normal" && style.whiteSpace !== "pre-wrap")) {
|
|
518
1553
|
return false;
|
|
519
1554
|
}
|
|
520
1555
|
}
|
|
@@ -545,63 +1580,134 @@ export class PaginationEngine {
|
|
|
545
1580
|
return fragment;
|
|
546
1581
|
}
|
|
547
1582
|
/**
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
1583
|
+
* Range clones repeat an inline ancestor when the split lands inside it. Keep
|
|
1584
|
+
* semantic wrappers (links, comments, formatting) on both sides, but retain a
|
|
1585
|
+
* duplicated HTML/editor identity only on the leading fragment. Targets that
|
|
1586
|
+
* occur wholly after the split are absent from `head` and remain on `tail`.
|
|
551
1587
|
*/
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
// so binary search avoids measuring every word in long body paragraphs.
|
|
568
|
-
while (low <= high) {
|
|
569
|
-
const middle = Math.floor((low + high) / 2);
|
|
570
|
-
const endpoint = endpoints[middle];
|
|
571
|
-
const headRange = document.createRange();
|
|
572
|
-
headRange.setStart(paragraph, 0);
|
|
573
|
-
headRange.setEnd(endpoint.node, endpoint.offset);
|
|
574
|
-
const headContents = headRange.cloneContents();
|
|
575
|
-
if (!this.hasVisibleFragmentText(headContents)) {
|
|
576
|
-
low = middle + 1;
|
|
1588
|
+
reconcileParagraphFragmentIdentities(head, tail) {
|
|
1589
|
+
const elements = (root, selector) => [
|
|
1590
|
+
...(root.matches(selector) ? [root] : []),
|
|
1591
|
+
...Array.from(root.querySelectorAll(selector)),
|
|
1592
|
+
];
|
|
1593
|
+
const headIds = new Set(elements(head, "[id]").map((element) => element.id));
|
|
1594
|
+
for (const element of elements(tail, "[id]")) {
|
|
1595
|
+
if (headIds.has(element.id))
|
|
1596
|
+
element.removeAttribute("id");
|
|
1597
|
+
}
|
|
1598
|
+
const headAnchors = new Set(elements(head, "[data-anchor]")
|
|
1599
|
+
.map((element) => element.dataset.anchor)
|
|
1600
|
+
.filter((anchor) => Boolean(anchor)));
|
|
1601
|
+
for (const element of elements(tail, "[data-anchor]")) {
|
|
1602
|
+
if (!element.dataset.anchor || !headAnchors.has(element.dataset.anchor))
|
|
577
1603
|
continue;
|
|
1604
|
+
element.removeAttribute("data-anchor");
|
|
1605
|
+
element.removeAttribute("data-committed-text");
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Range-clone the largest safe prefix accepted by `fits`. The measurement
|
|
1610
|
+
* policy stays with the caller, allowing body blocks and note bands to share
|
|
1611
|
+
* one DOM fragmenter while measuring in their respective layout contexts.
|
|
1612
|
+
*/
|
|
1613
|
+
splitParagraphAtLargestFit(paragraph, fits, options = {}) {
|
|
1614
|
+
const largestFit = (endpoints) => {
|
|
1615
|
+
// Prefix height is usually monotone, but selector-sensitive inline CSS
|
|
1616
|
+
// (`:last-child`, `:only-child`) can make a longer Range clone shorter.
|
|
1617
|
+
// Probe the largest legal endpoint first so the common non-monotone case
|
|
1618
|
+
// cannot be discarded by the binary search below.
|
|
1619
|
+
const lastEndpoint = endpoints[endpoints.length - 1];
|
|
1620
|
+
if (lastEndpoint) {
|
|
1621
|
+
const lastRange = this.document.createRange();
|
|
1622
|
+
lastRange.setStart(paragraph, 0);
|
|
1623
|
+
lastRange.setEnd(lastEndpoint.node, lastEndpoint.offset);
|
|
1624
|
+
const lastContents = lastRange.cloneContents();
|
|
1625
|
+
if (this.hasVisibleFragmentText(lastContents)) {
|
|
1626
|
+
const lastHead = this.createParagraphFragment(paragraph, lastRange, true, false);
|
|
1627
|
+
if (fits(lastHead))
|
|
1628
|
+
return { endpoint: lastEndpoint, head: lastHead };
|
|
1629
|
+
}
|
|
578
1630
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1631
|
+
let low = 0;
|
|
1632
|
+
let high = endpoints.length - 2;
|
|
1633
|
+
let best = null;
|
|
1634
|
+
while (low <= high) {
|
|
1635
|
+
this.checkpoint();
|
|
1636
|
+
const middle = Math.floor((low + high) / 2);
|
|
1637
|
+
const endpoint = endpoints[middle];
|
|
1638
|
+
const headRange = this.document.createRange();
|
|
1639
|
+
headRange.setStart(paragraph, 0);
|
|
1640
|
+
headRange.setEnd(endpoint.node, endpoint.offset);
|
|
1641
|
+
const headContents = headRange.cloneContents();
|
|
1642
|
+
if (!this.hasVisibleFragmentText(headContents)) {
|
|
1643
|
+
low = middle + 1;
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
const head = this.createParagraphFragment(paragraph, headRange, true, false);
|
|
1647
|
+
if (fits(head)) {
|
|
1648
|
+
best = { endpoint, head };
|
|
1649
|
+
low = middle + 1;
|
|
1650
|
+
}
|
|
1651
|
+
else {
|
|
1652
|
+
high = middle - 1;
|
|
1653
|
+
}
|
|
587
1654
|
}
|
|
1655
|
+
return best;
|
|
1656
|
+
};
|
|
1657
|
+
let endpoints = this.paragraphFragmentEndpoints(paragraph);
|
|
1658
|
+
let best = largestFit(endpoints);
|
|
1659
|
+
if (!best && options.emergencyGraphemeBreaks) {
|
|
1660
|
+
endpoints = this.paragraphFragmentEndpoints(paragraph, true);
|
|
1661
|
+
best = largestFit(endpoints);
|
|
1662
|
+
}
|
|
1663
|
+
if (!best && options.forceFirstOnNoFit && endpoints.length > 0) {
|
|
1664
|
+
const endpoint = endpoints[0];
|
|
1665
|
+
const headRange = this.document.createRange();
|
|
1666
|
+
headRange.setStart(paragraph, 0);
|
|
1667
|
+
headRange.setEnd(endpoint.node, endpoint.offset);
|
|
1668
|
+
best = {
|
|
1669
|
+
endpoint,
|
|
1670
|
+
head: this.createParagraphFragment(paragraph, headRange, true, false),
|
|
1671
|
+
};
|
|
588
1672
|
}
|
|
589
1673
|
if (!best) {
|
|
590
|
-
return
|
|
1674
|
+
return endpoints.length > 0 ? { kind: "no-fit" } : { kind: "indivisible" };
|
|
591
1675
|
}
|
|
592
|
-
const tailRange = document.createRange();
|
|
1676
|
+
const tailRange = this.document.createRange();
|
|
593
1677
|
tailRange.setStart(best.endpoint.node, best.endpoint.offset);
|
|
594
1678
|
tailRange.setEnd(paragraph, paragraph.childNodes.length);
|
|
595
1679
|
const tailContents = tailRange.cloneContents();
|
|
596
|
-
if (!this.hasVisibleFragmentText(tailContents))
|
|
1680
|
+
if (!this.hasVisibleFragmentText(tailContents))
|
|
1681
|
+
return { kind: "indivisible" };
|
|
1682
|
+
const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
|
|
1683
|
+
this.reconcileParagraphFragmentIdentities(best.head, tail);
|
|
1684
|
+
return {
|
|
1685
|
+
kind: "split",
|
|
1686
|
+
head: best.head,
|
|
1687
|
+
tail,
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Splits a simple paragraph at the largest DOM Range endpoint that fits the
|
|
1692
|
+
* currently available body space. The caller then processes the tail normally,
|
|
1693
|
+
* allowing it to fragment again on later pages when necessary.
|
|
1694
|
+
*/
|
|
1695
|
+
tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
|
|
1696
|
+
if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
|
|
597
1697
|
return null;
|
|
598
1698
|
}
|
|
599
|
-
const
|
|
600
|
-
|
|
1699
|
+
const split = this.splitParagraphAtLargestFit(block.element, (head) => {
|
|
1700
|
+
const measured = this.measureElement(head, dims, block.sectionIndex);
|
|
1701
|
+
return effectiveMarginTopPt + measured.heightPt <= availableHeightPt;
|
|
1702
|
+
});
|
|
1703
|
+
if (split.kind !== "split")
|
|
1704
|
+
return null;
|
|
1705
|
+
const headMeasured = this.measureElement(split.head, dims, block.sectionIndex);
|
|
1706
|
+
const tailMeasured = this.measureElement(split.tail, dims, block.sectionIndex);
|
|
601
1707
|
return [
|
|
602
1708
|
{
|
|
603
|
-
...
|
|
604
|
-
element:
|
|
1709
|
+
...headMeasured,
|
|
1710
|
+
element: split.head,
|
|
605
1711
|
keepWithNext: false,
|
|
606
1712
|
keepLines: false,
|
|
607
1713
|
pageBreakBefore: false,
|
|
@@ -609,7 +1715,7 @@ export class PaginationEngine {
|
|
|
609
1715
|
},
|
|
610
1716
|
{
|
|
611
1717
|
...tailMeasured,
|
|
612
|
-
element: tail,
|
|
1718
|
+
element: split.tail,
|
|
613
1719
|
keepWithNext: false,
|
|
614
1720
|
keepLines: false,
|
|
615
1721
|
pageBreakBefore: false,
|
|
@@ -687,7 +1793,11 @@ export class PaginationEngine {
|
|
|
687
1793
|
const registryEl = this.stagingElement.querySelector("#pagination-footnote-registry");
|
|
688
1794
|
if (!registryEl)
|
|
689
1795
|
return registry;
|
|
690
|
-
|
|
1796
|
+
// Only direct registry entries are definitions. Custom separator stories may
|
|
1797
|
+
// legitimately contain arbitrary converted markup, including stale semantic
|
|
1798
|
+
// attributes that must never be mistaken for another note definition.
|
|
1799
|
+
const entries = Array.from(registryEl.children)
|
|
1800
|
+
.filter((entry) => entry instanceof this.view.HTMLElement && entry.hasAttribute("data-footnote-id"));
|
|
691
1801
|
for (const entry of entries) {
|
|
692
1802
|
const footnoteId = entry.dataset.footnoteId;
|
|
693
1803
|
if (footnoteId) {
|
|
@@ -697,6 +1807,69 @@ export class PaginationEngine {
|
|
|
697
1807
|
}
|
|
698
1808
|
return registry;
|
|
699
1809
|
}
|
|
1810
|
+
/** Read Word's optional normal and continuation separator stories. */
|
|
1811
|
+
parseFootnoteSeparators() {
|
|
1812
|
+
const registry = this.stagingElement.querySelector("#pagination-footnote-registry");
|
|
1813
|
+
const clone = (kind) => {
|
|
1814
|
+
const source = Array.from(registry?.children ?? []).find((entry) => entry instanceof this.view.HTMLElement
|
|
1815
|
+
&& entry.getAttribute("data-footnote-separator") === kind);
|
|
1816
|
+
return source?.cloneNode(true);
|
|
1817
|
+
};
|
|
1818
|
+
return {
|
|
1819
|
+
normal: clone("normal") ?? null,
|
|
1820
|
+
continuation: clone("continuation") ?? null,
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
/** Append the exact separator story selected for this initial/continued note band. */
|
|
1824
|
+
appendFootnoteSeparator(container, continuation) {
|
|
1825
|
+
const kind = continuation ? "continuation" : "normal";
|
|
1826
|
+
const source = continuation ? this.footnoteContinuationSeparator : this.footnoteSeparator;
|
|
1827
|
+
if (!source) {
|
|
1828
|
+
const fallback = this.document.createElement("hr");
|
|
1829
|
+
fallback.dataset.footnoteSeparator = kind;
|
|
1830
|
+
container.appendChild(fallback);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
const clone = source.cloneNode(true);
|
|
1834
|
+
for (const element of [clone, ...Array.from(clone.querySelectorAll("*"))]) {
|
|
1835
|
+
element.removeAttribute("id");
|
|
1836
|
+
element.removeAttribute("data-anchor");
|
|
1837
|
+
element.removeAttribute("data-committed-text");
|
|
1838
|
+
element.removeAttribute("data-source-anchor-id");
|
|
1839
|
+
element.removeAttribute("data-page-fragment-id");
|
|
1840
|
+
element.removeAttribute("data-fragment-index");
|
|
1841
|
+
element.removeAttribute("data-footnote-id");
|
|
1842
|
+
element.removeAttribute("data-comment-id");
|
|
1843
|
+
element.removeAttribute("name");
|
|
1844
|
+
if (element instanceof this.view.HTMLAnchorElement
|
|
1845
|
+
&& element.getAttribute("href")?.startsWith("#")) {
|
|
1846
|
+
element.removeAttribute("href");
|
|
1847
|
+
}
|
|
1848
|
+
element.setAttribute("contenteditable", "false");
|
|
1849
|
+
}
|
|
1850
|
+
container.appendChild(clone);
|
|
1851
|
+
}
|
|
1852
|
+
/** Parses the hidden source notes used to render paginated margin comments. */
|
|
1853
|
+
parseCommentMarginRegistry() {
|
|
1854
|
+
const registry = new Map();
|
|
1855
|
+
const registryEl = this.stagingElement.querySelector("#pagination-comment-margin-registry");
|
|
1856
|
+
if (!registryEl)
|
|
1857
|
+
return registry;
|
|
1858
|
+
for (const entry of Array.from(registryEl.querySelectorAll("[data-comment-id]"))) {
|
|
1859
|
+
const commentId = entry.dataset.commentId;
|
|
1860
|
+
if (!commentId)
|
|
1861
|
+
continue;
|
|
1862
|
+
// A reply's note nests inside its thread root (issue #540), so every id in a thread
|
|
1863
|
+
// maps to the root's clone — a reply marker selects the whole thread, and the
|
|
1864
|
+
// placement loop dedupes by the clone's own id so the thread lands once per page.
|
|
1865
|
+
let root = entry;
|
|
1866
|
+
for (let parent = root.parentElement?.closest("[data-comment-id]"); parent && registryEl.contains(parent); parent = root.parentElement?.closest("[data-comment-id]")) {
|
|
1867
|
+
root = parent;
|
|
1868
|
+
}
|
|
1869
|
+
registry.set(commentId, root.cloneNode(true));
|
|
1870
|
+
}
|
|
1871
|
+
return registry;
|
|
1872
|
+
}
|
|
700
1873
|
/**
|
|
701
1874
|
* Extracts footnote reference IDs from an element.
|
|
702
1875
|
*/
|
|
@@ -711,6 +1884,103 @@ export class PaginationEngine {
|
|
|
711
1884
|
}
|
|
712
1885
|
return ids;
|
|
713
1886
|
}
|
|
1887
|
+
/** Clone a note child for the continuation queue with its original selector position. */
|
|
1888
|
+
cloneFootnoteElementForContinuation(element, sourceIndex) {
|
|
1889
|
+
const clone = element.cloneNode(true);
|
|
1890
|
+
clone.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, sourceIndex === 0 ? "first" : "later");
|
|
1891
|
+
return clone;
|
|
1892
|
+
}
|
|
1893
|
+
/** Remove identities that belong only to the initial registry presentation shell. */
|
|
1894
|
+
makeContinuationShellInert(element) {
|
|
1895
|
+
element.removeAttribute("id");
|
|
1896
|
+
element.removeAttribute("data-anchor");
|
|
1897
|
+
element.removeAttribute("data-committed-text");
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Build the exact continuation shape shared by measurement and paint.
|
|
1901
|
+
*
|
|
1902
|
+
* Keep the source `.footnote-item > .footnote-content` ancestry: document
|
|
1903
|
+
* CSS, inherited direction/language, and custom classes frequently target
|
|
1904
|
+
* those shells. Only the number and initial HTML/editor identities are
|
|
1905
|
+
* omitted. A hidden sentinel preserves `p:not(:first-of-type)` for a page
|
|
1906
|
+
* whose first carried element was a later source paragraph.
|
|
1907
|
+
*/
|
|
1908
|
+
createFootnoteContinuationWrapper(continuation, elements = continuation.remainingElements) {
|
|
1909
|
+
const source = this.footnoteRegistry.get(continuation.footnoteId);
|
|
1910
|
+
const wrapper = source
|
|
1911
|
+
? source.cloneNode(false)
|
|
1912
|
+
: this.document.createElement("div");
|
|
1913
|
+
this.makeContinuationShellInert(wrapper);
|
|
1914
|
+
wrapper.classList.add("footnote-continuation");
|
|
1915
|
+
wrapper.dataset.footnoteId = continuation.footnoteId;
|
|
1916
|
+
if (continuation.sourceAnchorId) {
|
|
1917
|
+
wrapper.dataset.sourceAnchorId = continuation.sourceAnchorId;
|
|
1918
|
+
}
|
|
1919
|
+
const sourceContent = source?.querySelector(".footnote-content");
|
|
1920
|
+
const content = sourceContent
|
|
1921
|
+
? sourceContent.cloneNode(false)
|
|
1922
|
+
: this.document.createElement("span");
|
|
1923
|
+
this.makeContinuationShellInert(content);
|
|
1924
|
+
if (!sourceContent)
|
|
1925
|
+
content.className = "footnote-content";
|
|
1926
|
+
const first = elements[0];
|
|
1927
|
+
if (first?.tagName === "P"
|
|
1928
|
+
&& first.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR) === "later") {
|
|
1929
|
+
const sentinel = this.document.createElement("p");
|
|
1930
|
+
sentinel.className = "footnote-continuation-position-sentinel";
|
|
1931
|
+
sentinel.setAttribute("aria-hidden", "true");
|
|
1932
|
+
sentinel.style.setProperty("display", "none", "important");
|
|
1933
|
+
content.appendChild(sentinel);
|
|
1934
|
+
}
|
|
1935
|
+
for (let index = 0; index < elements.length; index++) {
|
|
1936
|
+
this.checkpoint();
|
|
1937
|
+
const element = elements[index];
|
|
1938
|
+
const clone = element.cloneNode(true);
|
|
1939
|
+
clone.removeAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
|
|
1940
|
+
if (index === 0 && clone.tagName === "P") {
|
|
1941
|
+
// Without the initial number, a carried paragraph always starts a new
|
|
1942
|
+
// line even when source CSS made the note's first paragraph inline.
|
|
1943
|
+
clone.style.setProperty("display", "block", "important");
|
|
1944
|
+
}
|
|
1945
|
+
content.appendChild(clone);
|
|
1946
|
+
}
|
|
1947
|
+
wrapper.appendChild(content);
|
|
1948
|
+
return wrapper;
|
|
1949
|
+
}
|
|
1950
|
+
/** Build the exact partial-note item shape shared by measurement and paint. */
|
|
1951
|
+
createPartialFootnoteItem(footnote, fittingElements) {
|
|
1952
|
+
const item = footnote.cloneNode(false);
|
|
1953
|
+
const number = footnote.querySelector(".footnote-number");
|
|
1954
|
+
if (number)
|
|
1955
|
+
item.appendChild(number.cloneNode(true));
|
|
1956
|
+
const sourceContent = footnote.querySelector(".footnote-content");
|
|
1957
|
+
const content = sourceContent
|
|
1958
|
+
? sourceContent.cloneNode(false)
|
|
1959
|
+
: this.document.createElement("span");
|
|
1960
|
+
if (!sourceContent)
|
|
1961
|
+
content.className = "footnote-content";
|
|
1962
|
+
for (const element of fittingElements) {
|
|
1963
|
+
this.checkpoint();
|
|
1964
|
+
content.appendChild(element.cloneNode(true));
|
|
1965
|
+
}
|
|
1966
|
+
item.appendChild(content);
|
|
1967
|
+
return item;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Where a note measurement tree is attached.
|
|
1971
|
+
*
|
|
1972
|
+
* A note band is reserved from a measurement and then painted; if the two
|
|
1973
|
+
* happen under different inherited typography the painted notes overflow the
|
|
1974
|
+
* reserve, which is the invisible clipping this engine exists to avoid. The
|
|
1975
|
+
* registry lives in the staging tree but a band paints inside the output
|
|
1976
|
+
* container, so measure there. The host is never a page box, so a page's
|
|
1977
|
+
* `zoom` cannot scale a reserve that is accounted for in unscaled points.
|
|
1978
|
+
*/
|
|
1979
|
+
noteMeasurementHost() {
|
|
1980
|
+
return this.containerElement.isConnected
|
|
1981
|
+
? this.containerElement
|
|
1982
|
+
: this.stagingElement;
|
|
1983
|
+
}
|
|
714
1984
|
/**
|
|
715
1985
|
* Measures the height of footnotes for given IDs (in points).
|
|
716
1986
|
* Creates a temporary container to measure the footnotes.
|
|
@@ -718,80 +1988,217 @@ export class PaginationEngine {
|
|
|
718
1988
|
* @param contentWidth - Width for measurement
|
|
719
1989
|
* @param continuation - Optional continuation content to include first
|
|
720
1990
|
*/
|
|
721
|
-
measureFootnotesHeight(footnoteIds, contentWidth, continuation) {
|
|
1991
|
+
measureFootnotesHeight(footnoteIds, contentWidth, continuation, partialFootnotes) {
|
|
722
1992
|
const hasContinuation = continuation && continuation.remainingElements.length > 0;
|
|
723
|
-
if (
|
|
1993
|
+
if (footnoteIds.length === 0 && !hasContinuation) {
|
|
1994
|
+
return 0;
|
|
1995
|
+
}
|
|
1996
|
+
// Mirror addPageFootnotes: with no registry and nothing carried, no band is
|
|
1997
|
+
// painted, so reserving the separator's height would shrink every page's
|
|
1998
|
+
// body for a document whose note references resolve to nothing.
|
|
1999
|
+
if (this.footnoteRegistry.size === 0 && !hasContinuation) {
|
|
724
2000
|
return 0;
|
|
725
2001
|
}
|
|
726
2002
|
// Measure in the SAME styling context the notes render in: `.page-footnotes` carries
|
|
727
2003
|
// its own font-size and line-height, so measuring without the class sizes the note
|
|
728
2004
|
// block against body type and the reserve can never match what is drawn.
|
|
729
2005
|
// Create a temporary measurement container
|
|
730
|
-
const measureContainer = document.createElement("div");
|
|
2006
|
+
const measureContainer = this.document.createElement("div");
|
|
731
2007
|
measureContainer.style.position = "absolute";
|
|
732
2008
|
measureContainer.style.visibility = "hidden";
|
|
733
2009
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
734
2010
|
measureContainer.style.left = "-9999px";
|
|
735
2011
|
measureContainer.className = this.cssPrefix + "footnotes";
|
|
736
|
-
// Add separator
|
|
737
|
-
|
|
738
|
-
measureContainer.appendChild(hr);
|
|
2012
|
+
// Add the same normal/continuation separator story that will be painted.
|
|
2013
|
+
this.appendFootnoteSeparator(measureContainer, Boolean(hasContinuation));
|
|
739
2014
|
// Add continuation content first (if any)
|
|
740
2015
|
if (hasContinuation) {
|
|
741
|
-
|
|
742
|
-
contWrapper.className = "footnote-continuation";
|
|
743
|
-
for (const el of continuation.remainingElements) {
|
|
744
|
-
contWrapper.appendChild(el.cloneNode(true));
|
|
745
|
-
}
|
|
746
|
-
measureContainer.appendChild(contWrapper);
|
|
2016
|
+
measureContainer.appendChild(this.createFootnoteContinuationWrapper(continuation));
|
|
747
2017
|
}
|
|
748
2018
|
// Add footnotes
|
|
2019
|
+
const partialById = new Map(partialFootnotes?.map((partial) => [partial.footnoteId, partial]) ?? []);
|
|
749
2020
|
for (const id of footnoteIds) {
|
|
2021
|
+
this.checkpoint();
|
|
750
2022
|
const footnote = this.footnoteRegistry.get(id);
|
|
751
2023
|
if (footnote) {
|
|
752
|
-
|
|
2024
|
+
const partial = partialById.get(id);
|
|
2025
|
+
measureContainer.appendChild(partial
|
|
2026
|
+
? this.createPartialFootnoteItem(footnote, partial.fittingElements)
|
|
2027
|
+
: footnote.cloneNode(true));
|
|
753
2028
|
}
|
|
754
2029
|
}
|
|
755
2030
|
// Append to staging for measurement
|
|
756
|
-
this.
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
2031
|
+
this.noteMeasurementHost().appendChild(measureContainer);
|
|
2032
|
+
try {
|
|
2033
|
+
return pxToPt(measureContainer.getBoundingClientRect().height);
|
|
2034
|
+
}
|
|
2035
|
+
finally {
|
|
2036
|
+
measureContainer.remove();
|
|
2037
|
+
}
|
|
763
2038
|
}
|
|
764
2039
|
/**
|
|
765
|
-
*
|
|
2040
|
+
* Partition a continuation for one page's note band, preferring complete children
|
|
2041
|
+
* and range-fragmenting an eligible paragraph when necessary. Always advances by
|
|
2042
|
+
* at least one element so an indivisible oversized paragraph follows the established
|
|
2043
|
+
* clipped fallback without trapping pagination in a loop.
|
|
766
2044
|
*/
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
2045
|
+
splitContinuationForPage(continuation, availableHeightPt, contentWidth) {
|
|
2046
|
+
const fitting = [];
|
|
2047
|
+
let remaining = [];
|
|
2048
|
+
// Keep one live measurement tree and append each candidate exactly once.
|
|
2049
|
+
// Rebuilding `[...fitting, candidate]` for every child cloned 1+2+...+N
|
|
2050
|
+
// descendants before a page/resource cap could run, which is quadratic for
|
|
2051
|
+
// producer-authored notes containing thousands of tiny paragraphs/runs.
|
|
2052
|
+
const measureContainer = this.document.createElement("div");
|
|
772
2053
|
measureContainer.style.position = "absolute";
|
|
773
2054
|
measureContainer.style.visibility = "hidden";
|
|
774
2055
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
775
2056
|
measureContainer.style.left = "-9999px";
|
|
776
2057
|
measureContainer.className = this.cssPrefix + "footnotes";
|
|
777
|
-
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
2058
|
+
this.appendFootnoteSeparator(measureContainer, true);
|
|
2059
|
+
const wrapper = this.createFootnoteContinuationWrapper(continuation, []);
|
|
2060
|
+
const content = wrapper.querySelector(":scope > .footnote-content");
|
|
2061
|
+
if (!content) {
|
|
2062
|
+
throw new Error("Footnote continuation is missing its content shell");
|
|
2063
|
+
}
|
|
2064
|
+
measureContainer.appendChild(wrapper);
|
|
2065
|
+
this.noteMeasurementHost().appendChild(measureContainer);
|
|
2066
|
+
try {
|
|
2067
|
+
for (let index = 0; index < continuation.remainingElements.length; index++) {
|
|
2068
|
+
this.checkpoint();
|
|
2069
|
+
const element = continuation.remainingElements[index];
|
|
2070
|
+
const sourcePosition = element.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
|
|
2071
|
+
if (fitting.length === 0
|
|
2072
|
+
&& element.tagName === "P"
|
|
2073
|
+
&& element.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR) === "later") {
|
|
2074
|
+
const sentinel = this.document.createElement("p");
|
|
2075
|
+
sentinel.className = "footnote-continuation-position-sentinel";
|
|
2076
|
+
sentinel.setAttribute("aria-hidden", "true");
|
|
2077
|
+
sentinel.style.setProperty("display", "none", "important");
|
|
2078
|
+
content.appendChild(sentinel);
|
|
2079
|
+
}
|
|
2080
|
+
const candidate = element.cloneNode(true);
|
|
2081
|
+
candidate.removeAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
|
|
2082
|
+
if (fitting.length === 0 && candidate.tagName === "P") {
|
|
2083
|
+
candidate.style.setProperty("display", "block", "important");
|
|
2084
|
+
}
|
|
2085
|
+
content.appendChild(candidate);
|
|
2086
|
+
const candidateHeight = pxToPt(measureContainer.getBoundingClientRect().height);
|
|
2087
|
+
if (candidateHeight <= availableHeightPt) {
|
|
2088
|
+
fitting.push(element);
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
const canSplitCandidate = candidate.tagName === "P"
|
|
2092
|
+
&& this.canRangeFragmentParagraph(candidate);
|
|
2093
|
+
candidate.remove();
|
|
2094
|
+
let split = null;
|
|
2095
|
+
if (canSplitCandidate) {
|
|
2096
|
+
split = this.splitParagraphAtLargestFit(candidate, (head) => {
|
|
2097
|
+
content.appendChild(head);
|
|
2098
|
+
try {
|
|
2099
|
+
return pxToPt(measureContainer.getBoundingClientRect().height) <= availableHeightPt;
|
|
2100
|
+
}
|
|
2101
|
+
finally {
|
|
2102
|
+
head.remove();
|
|
2103
|
+
}
|
|
2104
|
+
}, {
|
|
2105
|
+
// Both fallbacks below cut where ordinary line breaking would not,
|
|
2106
|
+
// so they are only ever right for an element that owns the whole
|
|
2107
|
+
// band: if this page already carries earlier content, a paragraph
|
|
2108
|
+
// that will not start here belongs intact in the next note band.
|
|
2109
|
+
emergencyGraphemeBreaks: fitting.length === 0,
|
|
2110
|
+
// A continuation page owns the full note band. If even one legal
|
|
2111
|
+
// grapheme is taller than it, clip only that unit and keep draining.
|
|
2112
|
+
forceFirstOnNoFit: fitting.length === 0,
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
if (split?.kind === "split") {
|
|
2116
|
+
if (sourcePosition) {
|
|
2117
|
+
split.head.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, sourcePosition);
|
|
2118
|
+
split.tail.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, sourcePosition);
|
|
2119
|
+
}
|
|
2120
|
+
fitting.push(split.head);
|
|
2121
|
+
remaining = [split.tail, ...continuation.remainingElements.slice(index + 1)];
|
|
2122
|
+
}
|
|
2123
|
+
else if (fitting.length === 0) {
|
|
2124
|
+
// A genuinely indivisible first element retains the established visible
|
|
2125
|
+
// clipped fallback, but only for itself. Its siblings continue later.
|
|
2126
|
+
fitting.push(element);
|
|
2127
|
+
remaining = continuation.remainingElements.slice(index + 1);
|
|
2128
|
+
}
|
|
2129
|
+
else {
|
|
2130
|
+
remaining = continuation.remainingElements.slice(index);
|
|
2131
|
+
}
|
|
2132
|
+
break;
|
|
2133
|
+
}
|
|
783
2134
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
2135
|
+
finally {
|
|
2136
|
+
measureContainer.remove();
|
|
2137
|
+
}
|
|
2138
|
+
return {
|
|
2139
|
+
current: {
|
|
2140
|
+
footnoteId: continuation.footnoteId,
|
|
2141
|
+
sourceAnchorId: continuation.sourceAnchorId,
|
|
2142
|
+
remainingElements: fitting,
|
|
2143
|
+
},
|
|
2144
|
+
overflow: remaining.length > 0 ? {
|
|
2145
|
+
footnoteId: continuation.footnoteId,
|
|
2146
|
+
sourceAnchorId: continuation.sourceAnchorId,
|
|
2147
|
+
remainingElements: remaining,
|
|
2148
|
+
} : null,
|
|
2149
|
+
};
|
|
789
2150
|
}
|
|
790
2151
|
/**
|
|
791
2152
|
* Splits a footnote element into parts that fit within the available height.
|
|
792
2153
|
* Returns the elements that fit and the elements that need to continue.
|
|
793
2154
|
*/
|
|
794
|
-
splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth) {
|
|
2155
|
+
splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth, forceProgress = false, existingPayload) {
|
|
2156
|
+
// Registry entries are detached clones, so getComputedStyle() cannot validate
|
|
2157
|
+
// their real paragraph layout. Attach an exact clone in the rendered note
|
|
2158
|
+
// context for the duration of the conservative range-fragmentation check.
|
|
2159
|
+
const layoutContext = this.document.createElement("div");
|
|
2160
|
+
layoutContext.style.position = "absolute";
|
|
2161
|
+
layoutContext.style.visibility = "hidden";
|
|
2162
|
+
layoutContext.style.width = `${contentWidth}pt`;
|
|
2163
|
+
layoutContext.style.left = "-9999px";
|
|
2164
|
+
layoutContext.className = this.cssPrefix + "footnotes";
|
|
2165
|
+
this.appendFootnoteSeparator(layoutContext, false);
|
|
2166
|
+
const attachedFootnote = footnoteElement.cloneNode(true);
|
|
2167
|
+
layoutContext.appendChild(attachedFootnote);
|
|
2168
|
+
this.noteMeasurementHost().appendChild(layoutContext);
|
|
2169
|
+
// A second live tree represents the exact already-packed page payload plus
|
|
2170
|
+
// an initially empty partial item. Candidates are appended once and layout
|
|
2171
|
+
// is read in place, avoiding cumulative prefix re-cloning.
|
|
2172
|
+
const measureContainer = this.document.createElement("div");
|
|
2173
|
+
measureContainer.style.position = "absolute";
|
|
2174
|
+
measureContainer.style.visibility = "hidden";
|
|
2175
|
+
measureContainer.style.width = `${contentWidth}pt`;
|
|
2176
|
+
measureContainer.style.left = "-9999px";
|
|
2177
|
+
measureContainer.className = this.cssPrefix + "footnotes";
|
|
2178
|
+
const hasContinuation = Boolean(existingPayload?.continuation?.remainingElements.length);
|
|
2179
|
+
this.appendFootnoteSeparator(measureContainer, hasContinuation);
|
|
2180
|
+
if (hasContinuation) {
|
|
2181
|
+
measureContainer.appendChild(this.createFootnoteContinuationWrapper(existingPayload.continuation));
|
|
2182
|
+
}
|
|
2183
|
+
const partialById = new Map(existingPayload?.partialFootnotes?.map((partial) => [partial.footnoteId, partial]) ?? []);
|
|
2184
|
+
for (const id of existingPayload?.footnoteIds ?? []) {
|
|
2185
|
+
this.checkpoint();
|
|
2186
|
+
const source = this.footnoteRegistry.get(id);
|
|
2187
|
+
if (!source)
|
|
2188
|
+
continue;
|
|
2189
|
+
const partial = partialById.get(id);
|
|
2190
|
+
measureContainer.appendChild(partial
|
|
2191
|
+
? this.createPartialFootnoteItem(source, partial.fittingElements)
|
|
2192
|
+
: source.cloneNode(true));
|
|
2193
|
+
}
|
|
2194
|
+
const measuredPartial = this.createPartialFootnoteItem(attachedFootnote, []);
|
|
2195
|
+
const measuredContent = measuredPartial.querySelector(":scope > .footnote-content");
|
|
2196
|
+
if (!measuredContent) {
|
|
2197
|
+
layoutContext.remove();
|
|
2198
|
+
throw new Error("Footnote is missing its content shell");
|
|
2199
|
+
}
|
|
2200
|
+
measureContainer.appendChild(measuredPartial);
|
|
2201
|
+
this.noteMeasurementHost().appendChild(measureContainer);
|
|
795
2202
|
// Get child elements (paragraphs) of the footnote content.
|
|
796
2203
|
//
|
|
797
2204
|
// `fits` is spliced into a freshly built `.footnote-item` > `.footnote-content` wrapper by
|
|
@@ -800,86 +2207,78 @@ export class PaginationEngine {
|
|
|
800
2207
|
// inside another item's content span; the inner block-level div then broke the line, so the
|
|
801
2208
|
// note's number rendered alone above its text — the same visible symptom as the escaped-CSS
|
|
802
2209
|
// bug, from an unrelated cause, on the notes that happened to take a can't-split path.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const children = Array.from(footnoteContent.children);
|
|
812
|
-
if (children.length <= 1) {
|
|
813
|
-
// Single paragraph: can't split at paragraph level, but the whole content still fits.
|
|
814
|
-
return {
|
|
815
|
-
fits: children.map((el) => el.cloneNode(true)),
|
|
816
|
-
overflow: [],
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
const fits = [];
|
|
820
|
-
const overflow = [];
|
|
821
|
-
let currentHeight = 0;
|
|
822
|
-
// Measure separator line height
|
|
823
|
-
const hrMeasure = document.createElement("div");
|
|
824
|
-
hrMeasure.style.position = "absolute";
|
|
825
|
-
hrMeasure.style.visibility = "hidden";
|
|
826
|
-
hrMeasure.style.width = `${contentWidth}pt`;
|
|
827
|
-
hrMeasure.style.left = "-9999px";
|
|
828
|
-
hrMeasure.className = this.cssPrefix + "footnotes";
|
|
829
|
-
const hr = document.createElement("hr");
|
|
830
|
-
hrMeasure.appendChild(hr);
|
|
831
|
-
this.stagingElement.appendChild(hrMeasure);
|
|
832
|
-
const hrHeight = pxToPt(hrMeasure.getBoundingClientRect().height);
|
|
833
|
-
this.stagingElement.removeChild(hrMeasure);
|
|
834
|
-
currentHeight = hrHeight;
|
|
835
|
-
// Also account for footnote number
|
|
836
|
-
const footnoteNumber = footnoteElement.querySelector(".footnote-number");
|
|
837
|
-
for (let i = 0; i < children.length; i++) {
|
|
838
|
-
const child = children[i];
|
|
839
|
-
// Measure this element
|
|
840
|
-
const measureContainer = document.createElement("div");
|
|
841
|
-
measureContainer.style.position = "absolute";
|
|
842
|
-
measureContainer.style.visibility = "hidden";
|
|
843
|
-
measureContainer.style.width = `${contentWidth}pt`;
|
|
844
|
-
measureContainer.style.left = "-9999px";
|
|
845
|
-
measureContainer.className = this.cssPrefix + "footnotes";
|
|
846
|
-
measureContainer.appendChild(child.cloneNode(true));
|
|
847
|
-
this.stagingElement.appendChild(measureContainer);
|
|
848
|
-
const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
|
|
849
|
-
this.stagingElement.removeChild(measureContainer);
|
|
850
|
-
if (currentHeight + childHeight <= availableHeightPt) {
|
|
851
|
-
fits.push(child.cloneNode(true));
|
|
852
|
-
currentHeight += childHeight;
|
|
2210
|
+
try {
|
|
2211
|
+
const footnoteContent = attachedFootnote.querySelector(".footnote-content");
|
|
2212
|
+
if (!footnoteContent) {
|
|
2213
|
+
// No content structure to split — hand back the element's own children.
|
|
2214
|
+
return {
|
|
2215
|
+
fits: Array.from(attachedFootnote.children).map((el) => el.cloneNode(true)),
|
|
2216
|
+
overflow: [],
|
|
2217
|
+
};
|
|
853
2218
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
2219
|
+
const children = Array.from(footnoteContent.children);
|
|
2220
|
+
const fits = [];
|
|
2221
|
+
let overflow = [];
|
|
2222
|
+
for (let i = 0; i < children.length; i++) {
|
|
2223
|
+
this.checkpoint();
|
|
2224
|
+
const child = children[i];
|
|
2225
|
+
const measuredCandidate = child.cloneNode(true);
|
|
2226
|
+
measuredContent.appendChild(measuredCandidate);
|
|
2227
|
+
const candidateFits = pxToPt(measureContainer.getBoundingClientRect().height)
|
|
2228
|
+
<= availableHeightPt;
|
|
2229
|
+
if (candidateFits) {
|
|
2230
|
+
fits.push(child.cloneNode(true));
|
|
2231
|
+
continue;
|
|
2232
|
+
}
|
|
2233
|
+
measuredCandidate.remove();
|
|
2234
|
+
const split = this.canRangeFragmentParagraph(child, true)
|
|
2235
|
+
? this.splitParagraphAtLargestFit(child, (head) => {
|
|
2236
|
+
measuredContent.appendChild(head);
|
|
2237
|
+
try {
|
|
2238
|
+
return pxToPt(measureContainer.getBoundingClientRect().height)
|
|
2239
|
+
<= availableHeightPt;
|
|
2240
|
+
}
|
|
2241
|
+
finally {
|
|
2242
|
+
head.remove();
|
|
2243
|
+
}
|
|
2244
|
+
}, {
|
|
2245
|
+
// Both fallbacks cut where ordinary line breaking would not, so they
|
|
2246
|
+
// are only ever right for an element that owns the whole band: with
|
|
2247
|
+
// earlier siblings already packed here, a paragraph that will not
|
|
2248
|
+
// start belongs intact in the next note band.
|
|
2249
|
+
emergencyGraphemeBreaks: fits.length === 0 && forceProgress,
|
|
2250
|
+
forceFirstOnNoFit: fits.length === 0 && forceProgress,
|
|
2251
|
+
})
|
|
2252
|
+
: null;
|
|
2253
|
+
if (split?.kind === "split") {
|
|
2254
|
+
fits.push(split.head);
|
|
2255
|
+
split.tail.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, i === 0 ? "first" : "later");
|
|
2256
|
+
overflow = [split.tail, ...children.slice(i + 1).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + 1 + offset))];
|
|
2257
|
+
}
|
|
2258
|
+
else if (split?.kind === "no-fit") {
|
|
2259
|
+
// The paragraph is splittable, but this citation page has less than
|
|
2260
|
+
// one line left. Defer it whole so a fresh note band can try again.
|
|
2261
|
+
overflow = children.slice(i).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + offset));
|
|
2262
|
+
}
|
|
2263
|
+
else if (fits.length === 0 && forceProgress) {
|
|
2264
|
+
// Preserve the one-element clipping fallback for content that cannot be
|
|
2265
|
+
// range-fragmented only on a dedicated full note band, while allowing
|
|
2266
|
+
// every later sibling to continue. A residual citation-page band must
|
|
2267
|
+
// defer the whole element: it may fit untouched on the next page.
|
|
2268
|
+
fits.push(child.cloneNode(true));
|
|
2269
|
+
overflow = children.slice(i + 1).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + 1 + offset));
|
|
2270
|
+
}
|
|
2271
|
+
else {
|
|
2272
|
+
overflow = children.slice(i).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + offset));
|
|
858
2273
|
}
|
|
859
2274
|
break;
|
|
860
2275
|
}
|
|
2276
|
+
return { fits, overflow };
|
|
2277
|
+
}
|
|
2278
|
+
finally {
|
|
2279
|
+
measureContainer.remove();
|
|
2280
|
+
layoutContext.remove();
|
|
861
2281
|
}
|
|
862
|
-
return { fits, overflow };
|
|
863
|
-
}
|
|
864
|
-
/**
|
|
865
|
-
* Measures a single footnote's height.
|
|
866
|
-
*/
|
|
867
|
-
measureSingleFootnoteHeight(footnoteId, contentWidth) {
|
|
868
|
-
const footnote = this.footnoteRegistry.get(footnoteId);
|
|
869
|
-
if (!footnote)
|
|
870
|
-
return 0;
|
|
871
|
-
const measureContainer = document.createElement("div");
|
|
872
|
-
measureContainer.style.position = "absolute";
|
|
873
|
-
measureContainer.style.visibility = "hidden";
|
|
874
|
-
measureContainer.style.width = `${contentWidth}pt`;
|
|
875
|
-
measureContainer.style.left = "-9999px";
|
|
876
|
-
measureContainer.className = this.cssPrefix + "footnotes";
|
|
877
|
-
measureContainer.appendChild(footnote.cloneNode(true));
|
|
878
|
-
this.stagingElement.appendChild(measureContainer);
|
|
879
|
-
const rect = measureContainer.getBoundingClientRect();
|
|
880
|
-
const heightPt = pxToPt(rect.height);
|
|
881
|
-
this.stagingElement.removeChild(measureContainer);
|
|
882
|
-
return heightPt;
|
|
883
2282
|
}
|
|
884
2283
|
/**
|
|
885
2284
|
* Adds footnotes to a page container, including continuation content.
|
|
@@ -892,11 +2291,9 @@ export class PaginationEngine {
|
|
|
892
2291
|
if (this.footnoteRegistry.size === 0 && !hasContinuation) {
|
|
893
2292
|
return;
|
|
894
2293
|
}
|
|
895
|
-
// Create a set of partial footnote IDs for quick lookup
|
|
896
|
-
const partialFootnoteIds = new Set(partialFootnotes?.map(p => p.footnoteId) || []);
|
|
897
2294
|
// Calculate max height for footnotes area (content height minus margin for body content)
|
|
898
2295
|
const maxFootnoteHeight = Math.min(footnoteHeight, bands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO);
|
|
899
|
-
const footnotesDiv = document.createElement("div");
|
|
2296
|
+
const footnotesDiv = this.document.createElement("div");
|
|
900
2297
|
footnotesDiv.className = `${this.cssPrefix}footnotes`;
|
|
901
2298
|
footnotesDiv.style.position = "absolute";
|
|
902
2299
|
// Notes sit at the FOOT OF THE BODY BAND, not at the bottom margin: a footer taller than
|
|
@@ -908,17 +2305,10 @@ export class PaginationEngine {
|
|
|
908
2305
|
// Constrain height and clip overflow to prevent footnotes covering body content
|
|
909
2306
|
footnotesDiv.style.maxHeight = `${maxFootnoteHeight}pt`;
|
|
910
2307
|
footnotesDiv.style.overflow = "hidden";
|
|
911
|
-
|
|
912
|
-
const hr = document.createElement("hr");
|
|
913
|
-
footnotesDiv.appendChild(hr);
|
|
2308
|
+
this.appendFootnoteSeparator(footnotesDiv, Boolean(hasContinuation));
|
|
914
2309
|
// Add continuation content first (if any)
|
|
915
2310
|
if (hasContinuation) {
|
|
916
|
-
|
|
917
|
-
contWrapper.className = "footnote-continuation";
|
|
918
|
-
for (const el of continuation.remainingElements) {
|
|
919
|
-
contWrapper.appendChild(el.cloneNode(true));
|
|
920
|
-
}
|
|
921
|
-
footnotesDiv.appendChild(contWrapper);
|
|
2311
|
+
footnotesDiv.appendChild(this.createFootnoteContinuationWrapper(continuation));
|
|
922
2312
|
}
|
|
923
2313
|
// Clone footnotes in order of appearance
|
|
924
2314
|
for (const id of footnoteIds) {
|
|
@@ -928,22 +2318,7 @@ export class PaginationEngine {
|
|
|
928
2318
|
// Render partial footnote (only the fitting elements)
|
|
929
2319
|
const footnote = this.footnoteRegistry.get(id);
|
|
930
2320
|
if (footnote) {
|
|
931
|
-
|
|
932
|
-
partialDiv.className = "footnote-item";
|
|
933
|
-
partialDiv.dataset.footnoteId = id;
|
|
934
|
-
// Add footnote number
|
|
935
|
-
const numberSpan = footnote.querySelector(".footnote-number");
|
|
936
|
-
if (numberSpan) {
|
|
937
|
-
partialDiv.appendChild(numberSpan.cloneNode(true));
|
|
938
|
-
}
|
|
939
|
-
// Add only the fitting content
|
|
940
|
-
const contentSpan = document.createElement("span");
|
|
941
|
-
contentSpan.className = "footnote-content";
|
|
942
|
-
for (const el of partial.fittingElements) {
|
|
943
|
-
contentSpan.appendChild(el.cloneNode(true));
|
|
944
|
-
}
|
|
945
|
-
partialDiv.appendChild(contentSpan);
|
|
946
|
-
footnotesDiv.appendChild(partialDiv);
|
|
2321
|
+
footnotesDiv.appendChild(this.createPartialFootnoteItem(footnote, partial.fittingElements));
|
|
947
2322
|
}
|
|
948
2323
|
}
|
|
949
2324
|
else {
|
|
@@ -956,10 +2331,8 @@ export class PaginationEngine {
|
|
|
956
2331
|
}
|
|
957
2332
|
pageBox.appendChild(footnotesDiv);
|
|
958
2333
|
}
|
|
959
|
-
/**
|
|
960
|
-
|
|
961
|
-
*/
|
|
962
|
-
selectHeader(sectionIndex, pageInSection, globalPageNumber) {
|
|
2334
|
+
/** Select the section's first/odd/even header from its one-based page position. */
|
|
2335
|
+
selectHeader(sectionIndex, pageInSection, displayedPageNumber) {
|
|
963
2336
|
const sectionHf = this.hfRegistry.get(sectionIndex);
|
|
964
2337
|
if (!sectionHf)
|
|
965
2338
|
return undefined;
|
|
@@ -967,17 +2340,19 @@ export class PaginationEngine {
|
|
|
967
2340
|
if (pageInSection === 1 && sectionHf.headerFirst) {
|
|
968
2341
|
return sectionHf.headerFirst;
|
|
969
2342
|
}
|
|
970
|
-
//
|
|
971
|
-
|
|
2343
|
+
// ECMA-376 §17.10.5 hangs the even story on "even numbered pages": the PAGE NUMBER,
|
|
2344
|
+
// which keeps counting across a section boundary unless w:pgNumType restarts it (and a
|
|
2345
|
+
// restart moves the parity with it). Word and LibreOffice both render DB001-Sections
|
|
2346
|
+
// this way; selecting by position-in-section flips every story of a section that
|
|
2347
|
+
// begins on an even page (issue #536).
|
|
2348
|
+
if (displayedPageNumber % 2 === 0 && sectionHf.headerEven) {
|
|
972
2349
|
return sectionHf.headerEven;
|
|
973
2350
|
}
|
|
974
2351
|
// Default (odd) pages
|
|
975
2352
|
return sectionHf.headerDefault;
|
|
976
2353
|
}
|
|
977
|
-
/**
|
|
978
|
-
|
|
979
|
-
*/
|
|
980
|
-
selectFooter(sectionIndex, pageInSection, globalPageNumber) {
|
|
2354
|
+
/** Select the section's first/odd/even footer from its one-based page position. */
|
|
2355
|
+
selectFooter(sectionIndex, pageInSection, displayedPageNumber) {
|
|
981
2356
|
const sectionHf = this.hfRegistry.get(sectionIndex);
|
|
982
2357
|
if (!sectionHf)
|
|
983
2358
|
return undefined;
|
|
@@ -985,8 +2360,7 @@ export class PaginationEngine {
|
|
|
985
2360
|
if (pageInSection === 1 && sectionHf.footerFirst) {
|
|
986
2361
|
return sectionHf.footerFirst;
|
|
987
2362
|
}
|
|
988
|
-
|
|
989
|
-
if (globalPageNumber % 2 === 0 && sectionHf.footerEven) {
|
|
2363
|
+
if (displayedPageNumber % 2 === 0 && sectionHf.footerEven) {
|
|
990
2364
|
return sectionHf.footerEven;
|
|
991
2365
|
}
|
|
992
2366
|
// Default (odd) pages
|
|
@@ -1002,18 +2376,18 @@ export class PaginationEngine {
|
|
|
1002
2376
|
* Deterministic: it depends only on the section's page setup and the registry's pre-measured
|
|
1003
2377
|
* story heights, never on the page's content, which is what keeps it lazy-loading compatible.
|
|
1004
2378
|
*/
|
|
1005
|
-
getPageBands(dims, sectionIndex, pageInSection,
|
|
2379
|
+
getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber) {
|
|
1006
2380
|
const sectionHf = this.hfRegistry.get(sectionIndex);
|
|
1007
|
-
return resolvePageBands(dims, this.selectStoryHeight(sectionHf?.headerFirstHeight, sectionHf?.headerEvenHeight, sectionHf?.headerDefaultHeight, pageInSection,
|
|
2381
|
+
return resolvePageBands(dims, this.selectStoryHeight(sectionHf?.headerFirstHeight, sectionHf?.headerEvenHeight, sectionHf?.headerDefaultHeight, pageInSection, displayedPageNumber), this.selectStoryHeight(sectionHf?.footerFirstHeight, sectionHf?.footerEvenHeight, sectionHf?.footerDefaultHeight, pageInSection, displayedPageNumber));
|
|
1008
2382
|
}
|
|
1009
2383
|
/**
|
|
1010
2384
|
* The measured height of the running story this page position selects, mirroring
|
|
1011
2385
|
* {@link selectHeader}/{@link selectFooter}. Zero when the page has no such story.
|
|
1012
2386
|
*/
|
|
1013
|
-
selectStoryHeight(first, even, fallback, pageInSection,
|
|
2387
|
+
selectStoryHeight(first, even, fallback, pageInSection, displayedPageNumber) {
|
|
1014
2388
|
if (pageInSection === 1 && first != null)
|
|
1015
2389
|
return first;
|
|
1016
|
-
if (
|
|
2390
|
+
if (displayedPageNumber % 2 === 0 && even != null)
|
|
1017
2391
|
return even;
|
|
1018
2392
|
return fallback ?? 0;
|
|
1019
2393
|
}
|
|
@@ -1027,7 +2401,7 @@ export class PaginationEngine {
|
|
|
1027
2401
|
*/
|
|
1028
2402
|
measureHeaderFooterHeight(source, contentWidth) {
|
|
1029
2403
|
// Create a temporary measurement container
|
|
1030
|
-
const measureContainer = document.createElement("div");
|
|
2404
|
+
const measureContainer = this.document.createElement("div");
|
|
1031
2405
|
measureContainer.style.position = "absolute";
|
|
1032
2406
|
measureContainer.style.visibility = "hidden";
|
|
1033
2407
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
@@ -1050,25 +2424,49 @@ export class PaginationEngine {
|
|
|
1050
2424
|
* Implements a single-pass, forward-only algorithm that is compatible with future lazy loading.
|
|
1051
2425
|
* Supports footnote continuation - long footnotes can split across pages.
|
|
1052
2426
|
*/
|
|
1053
|
-
flowToPages(blocks, dims, startPageNumber, sectionIndex) {
|
|
2427
|
+
flowToPages(blocks, dims, startPageNumber, sectionIndex, sectionDimensions) {
|
|
1054
2428
|
const pages = [];
|
|
1055
2429
|
let currentContent = [];
|
|
1056
2430
|
let pageNumber = startPageNumber;
|
|
1057
|
-
|
|
1058
|
-
|
|
2431
|
+
let pageSectionIndex = sectionIndex;
|
|
2432
|
+
// A continuous section can begin on a page owned by its predecessor. That shared physical
|
|
2433
|
+
// page is still page 1 of the later section, so its first independently owned page is page 2.
|
|
2434
|
+
const sectionStartPages = new Map([[sectionIndex, startPageNumber]]);
|
|
2435
|
+
const pageInSection = (owner = pageSectionIndex, physicalPage = pageNumber) => physicalPage - (sectionStartPages.get(owner) ?? physicalPage) + 1;
|
|
2436
|
+
const dimensionsFor = (owner = pageSectionIndex) => sectionDimensions.get(owner) ?? dims;
|
|
2437
|
+
const markSectionPlaced = (owner) => {
|
|
2438
|
+
if (!sectionStartPages.has(owner))
|
|
2439
|
+
sectionStartPages.set(owner, pageNumber);
|
|
2440
|
+
};
|
|
2441
|
+
const previousBox = this.containerElement.querySelector(`.${this.cssPrefix}box:last-of-type`);
|
|
2442
|
+
let precedingDisplayedPageNumber = parseInt(previousBox?.dataset.displayedPageNumber ?? "0", 10);
|
|
2443
|
+
const displayedPageNumber = (owner = pageSectionIndex, ownerPageInSection = pageInSection(owner), physicalPage = pageNumber) => {
|
|
2444
|
+
const numbering = this.pageNumbering.get(owner) ?? {};
|
|
2445
|
+
if (numbering.start !== undefined) {
|
|
2446
|
+
return numbering.start + ownerPageInSection - 1;
|
|
2447
|
+
}
|
|
2448
|
+
const ownerNumbering = this.pageNumbering.get(pageSectionIndex) ?? {};
|
|
2449
|
+
const currentPhysicalNumber = ownerNumbering.start !== undefined
|
|
2450
|
+
? ownerNumbering.start + pageInSection(pageSectionIndex) - 1
|
|
2451
|
+
: precedingDisplayedPageNumber + 1;
|
|
2452
|
+
return currentPhysicalNumber + physicalPage - pageNumber;
|
|
2453
|
+
};
|
|
1059
2454
|
// Get effective content height for first page (accounts for header/footer sizes)
|
|
1060
|
-
let { bodyHeight: effectiveContentHeight } = this.getPageBands(
|
|
2455
|
+
let { bodyHeight: effectiveContentHeight } = this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber());
|
|
1061
2456
|
let remainingHeight = effectiveContentHeight;
|
|
1062
2457
|
// Track the previous block's bottom margin for margin collapsing
|
|
1063
2458
|
let prevMarginBottomPt = 0;
|
|
1064
2459
|
// Track footnote IDs for the current page
|
|
1065
2460
|
let currentFootnoteIds = [];
|
|
2461
|
+
let currentPageHasFootnoteReference = false;
|
|
1066
2462
|
// Track height consumed by footnotes on current page
|
|
1067
2463
|
let currentFootnoteHeight = 0;
|
|
1068
2464
|
// Track footnote continuation for current page (from previous page)
|
|
1069
2465
|
let currentContinuation = this.pendingFootnoteContinuation;
|
|
1070
2466
|
// Track any new continuation that will carry to next page
|
|
1071
2467
|
let nextPageContinuation = null;
|
|
2468
|
+
let currentContinuationPartitioned = false;
|
|
2469
|
+
let currentPageAdmitted = false;
|
|
1072
2470
|
/**
|
|
1073
2471
|
* Whole notes that could not be started on this page and must render on the next one.
|
|
1074
2472
|
*
|
|
@@ -1083,29 +2481,133 @@ export class PaginationEngine {
|
|
|
1083
2481
|
let deferredFootnoteIds = [];
|
|
1084
2482
|
// Track partial footnotes for current page (footnotes that were split)
|
|
1085
2483
|
let currentPartialFootnotes = [];
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
2484
|
+
const adoptEmptyPageOwner = (owner) => {
|
|
2485
|
+
pageSectionIndex = owner;
|
|
2486
|
+
const bands = this.getPageBands(dimensionsFor(owner), owner, pageInSection(owner), displayedPageNumber(owner));
|
|
2487
|
+
effectiveContentHeight = bands.bodyHeight;
|
|
2488
|
+
remainingHeight = effectiveContentHeight;
|
|
2489
|
+
};
|
|
2490
|
+
const admitCurrentPage = () => {
|
|
2491
|
+
if (currentPageAdmitted)
|
|
2492
|
+
return;
|
|
2493
|
+
this.admitPageAllocation();
|
|
2494
|
+
currentPageAdmitted = true;
|
|
2495
|
+
};
|
|
2496
|
+
const prepareCurrentContinuation = () => {
|
|
2497
|
+
if (currentContinuationPartitioned
|
|
2498
|
+
|| (currentContinuation?.remainingElements.length ?? 0) === 0)
|
|
2499
|
+
return;
|
|
2500
|
+
// A continuation guarantees that this physical page will exist. Admit it
|
|
2501
|
+
// before touching the carried DOM, then partition once and retain that
|
|
2502
|
+
// exact head while body placement is decided.
|
|
2503
|
+
admitCurrentPage();
|
|
2504
|
+
const ownedDimensions = dimensionsFor();
|
|
2505
|
+
const pageBands = this.getPageBands(ownedDimensions, pageSectionIndex, pageInSection(), displayedPageNumber());
|
|
2506
|
+
const partition = this.splitContinuationForPage(currentContinuation, pageBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO, ownedDimensions.contentWidth);
|
|
2507
|
+
currentContinuation = partition.current;
|
|
2508
|
+
if (partition.overflow)
|
|
2509
|
+
nextPageContinuation = partition.overflow;
|
|
2510
|
+
currentContinuationPartitioned = true;
|
|
2511
|
+
currentFootnoteHeight = this.measureFootnotesHeight(currentFootnoteIds, ownedDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
|
|
2512
|
+
};
|
|
2513
|
+
const finishPage = (nextPageSectionIndex = pageSectionIndex, forceEmptyPage = false) => {
|
|
1091
2514
|
const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0;
|
|
1092
|
-
if (currentContent.length === 0 &&
|
|
2515
|
+
if (!forceEmptyPage && currentContent.length === 0 && currentFootnoteIds.length === 0
|
|
2516
|
+
&& !hasCurrentContinuation) {
|
|
2517
|
+
adoptEmptyPageOwner(nextPageSectionIndex);
|
|
1093
2518
|
return;
|
|
1094
|
-
|
|
2519
|
+
}
|
|
2520
|
+
prepareCurrentContinuation();
|
|
2521
|
+
// Pages without a carried continuation are charged here. Continuation
|
|
2522
|
+
// pages were charged before their page-owned partition above.
|
|
2523
|
+
admitCurrentPage();
|
|
2524
|
+
let pageContinuation = currentContinuation;
|
|
2525
|
+
const ownedPageInSection = pageInSection();
|
|
2526
|
+
const ownedDimensions = dimensionsFor();
|
|
2527
|
+
const ownedDisplayedPageNumber = displayedPageNumber(pageSectionIndex, ownedPageInSection);
|
|
2528
|
+
const pageBands = this.getPageBands(ownedDimensions, pageSectionIndex, ownedPageInSection, ownedDisplayedPageNumber);
|
|
2529
|
+
const maxFootnoteHeight = pageBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO;
|
|
2530
|
+
// `prepareCurrentContinuation` has already packed the exact head for this
|
|
2531
|
+
// page. Keeping that partition stable lets body fit decisions share the
|
|
2532
|
+
// remaining band without rescanning the entire tail.
|
|
2533
|
+
pageContinuation = currentContinuation;
|
|
2534
|
+
// Recompute the entire painted payload after continuation partitioning.
|
|
2535
|
+
// Measuring only the carried head here discarded the reserve for a newer
|
|
2536
|
+
// whole/partial note and let the clipped note band silently consume it.
|
|
2537
|
+
currentFootnoteHeight = this.measureFootnotesHeight(currentFootnoteIds, ownedDimensions.contentWidth, pageContinuation, currentPartialFootnotes);
|
|
2538
|
+
// A final body page can seed the next page with several whole notes. Partition that queue
|
|
2539
|
+
// before materializing a note-only page: addPageFootnotes deliberately clips its band, so
|
|
2540
|
+
// placing the entire queue in one page would keep the DOM nodes while making later notes
|
|
2541
|
+
// invisible (and therefore absent from a geometry-clipped PageMap).
|
|
2542
|
+
if (currentContent.length === 0 && currentFootnoteIds.length > 0
|
|
2543
|
+
&& currentPartialFootnotes.length === 0) {
|
|
2544
|
+
const fittingIds = [];
|
|
2545
|
+
for (let index = 0; index < currentFootnoteIds.length; index++) {
|
|
2546
|
+
const footnoteId = currentFootnoteIds[index];
|
|
2547
|
+
const candidateIds = [...fittingIds, footnoteId];
|
|
2548
|
+
const candidateHeight = this.measureFootnotesHeight(candidateIds, ownedDimensions.contentWidth, pageContinuation);
|
|
2549
|
+
const guardedCandidateHeight = candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT;
|
|
2550
|
+
if (guardedCandidateHeight <= maxFootnoteHeight) {
|
|
2551
|
+
fittingIds.push(footnoteId);
|
|
2552
|
+
currentFootnoteHeight = guardedCandidateHeight;
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2555
|
+
const hasPageContinuation = (pageContinuation?.remainingElements.length ?? 0) > 0;
|
|
2556
|
+
if (fittingIds.length === 0 && !hasPageContinuation) {
|
|
2557
|
+
// One note alone is taller than the note band. Split at the same safe paragraph
|
|
2558
|
+
// boundaries used during body flow; if it is indivisible, preserve the established
|
|
2559
|
+
// visible clipped fallback while still advancing the queue.
|
|
2560
|
+
const source = this.footnoteRegistry.get(footnoteId);
|
|
2561
|
+
const split = source
|
|
2562
|
+
? this.splitFootnoteToFit(source, maxFootnoteHeight - FOOTNOTE_MEASUREMENT_GUARD_PT, ownedDimensions.contentWidth, true)
|
|
2563
|
+
: null;
|
|
2564
|
+
fittingIds.push(footnoteId);
|
|
2565
|
+
if (source && split && split.fits.length > 0 && split.overflow.length > 0) {
|
|
2566
|
+
currentPartialFootnotes.push({ footnoteId, fittingElements: split.fits });
|
|
2567
|
+
nextPageContinuation = {
|
|
2568
|
+
footnoteId,
|
|
2569
|
+
sourceAnchorId: source.dataset.sourceAnchorId,
|
|
2570
|
+
remainingElements: split.overflow,
|
|
2571
|
+
};
|
|
2572
|
+
currentFootnoteHeight = maxFootnoteHeight;
|
|
2573
|
+
}
|
|
2574
|
+
else {
|
|
2575
|
+
currentFootnoteHeight = guardedCandidateHeight;
|
|
2576
|
+
}
|
|
2577
|
+
deferredFootnoteIds.push(...currentFootnoteIds.slice(index + 1));
|
|
2578
|
+
}
|
|
2579
|
+
else {
|
|
2580
|
+
deferredFootnoteIds.push(...currentFootnoteIds.slice(index));
|
|
2581
|
+
}
|
|
2582
|
+
break;
|
|
2583
|
+
}
|
|
2584
|
+
currentFootnoteIds = fittingIds;
|
|
2585
|
+
}
|
|
2586
|
+
const page = this.createPage(ownedDimensions, pageNumber, pageSectionIndex, ownedDisplayedPageNumber, currentContent, ownedPageInSection, currentFootnoteIds, currentFootnoteHeight, pageContinuation, currentPartialFootnotes.length > 0 ? currentPartialFootnotes : undefined, false, currentPageAdmitted);
|
|
1095
2587
|
pages.push(page);
|
|
2588
|
+
precedingDisplayedPageNumber = ownedDisplayedPageNumber;
|
|
1096
2589
|
pageNumber++;
|
|
1097
|
-
pageInSection++;
|
|
1098
2590
|
currentContent = [];
|
|
2591
|
+
pageSectionIndex = nextPageSectionIndex;
|
|
2592
|
+
if (!sectionStartPages.has(pageSectionIndex)) {
|
|
2593
|
+
// A carried note can own pages before the first body block of a new
|
|
2594
|
+
// section lands. Count those physical pages in that section so first /
|
|
2595
|
+
// odd / even stories and restarted displayed numbering advance once.
|
|
2596
|
+
sectionStartPages.set(pageSectionIndex, pageNumber);
|
|
2597
|
+
}
|
|
1099
2598
|
// Get effective content height for new page position
|
|
1100
|
-
const newBands = this.getPageBands(
|
|
2599
|
+
const newBands = this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber());
|
|
1101
2600
|
effectiveContentHeight = newBands.bodyHeight;
|
|
1102
2601
|
remainingHeight = effectiveContentHeight;
|
|
1103
2602
|
prevMarginBottomPt = 0; // Reset margin tracking for new page
|
|
1104
2603
|
currentFootnoteIds = []; // Reset footnotes for new page
|
|
2604
|
+
currentPageHasFootnoteReference = false;
|
|
1105
2605
|
currentPartialFootnotes = []; // Reset partial footnotes for new page
|
|
1106
2606
|
// Carry over continuation to next page
|
|
1107
2607
|
currentContinuation = nextPageContinuation;
|
|
1108
2608
|
nextPageContinuation = null;
|
|
2609
|
+
currentContinuationPartitioned = false;
|
|
2610
|
+
currentPageAdmitted = false;
|
|
1109
2611
|
// Notes that never got started land at the top of the new page's note area. They are
|
|
1110
2612
|
// ordinary footnotes from here on, so the normal fitting path handles them — and because
|
|
1111
2613
|
// this page is fresh, the space they were denied now exists.
|
|
@@ -1113,27 +2615,86 @@ export class PaginationEngine {
|
|
|
1113
2615
|
currentFootnoteIds = [...deferredFootnoteIds];
|
|
1114
2616
|
deferredFootnoteIds = [];
|
|
1115
2617
|
}
|
|
1116
|
-
//
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
if (currentFootnoteIds.length > 0) {
|
|
1124
|
-
currentFootnoteHeight += this.measureFootnotesHeight(currentFootnoteIds, dims.contentWidth, null);
|
|
1125
|
-
}
|
|
2618
|
+
// Whole deferred notes have no carried DOM and can be measured directly.
|
|
2619
|
+
// A continuation is partitioned lazily after the prospective page has
|
|
2620
|
+
// passed its resource admission check, so never clone its entire tail here.
|
|
2621
|
+
const nextDimensions = dimensionsFor();
|
|
2622
|
+
currentFootnoteHeight = currentContinuation
|
|
2623
|
+
? 0
|
|
2624
|
+
: this.measureFootnotesHeight(currentFootnoteIds, nextDimensions.contentWidth);
|
|
1126
2625
|
};
|
|
2626
|
+
const pageHasPayload = () => currentContent.length > 0
|
|
2627
|
+
|| currentFootnoteIds.length > 0
|
|
2628
|
+
|| (currentContinuation?.remainingElements.length ?? 0) > 0;
|
|
1127
2629
|
for (let i = 0; i < blocks.length; i++) {
|
|
2630
|
+
this.checkpoint();
|
|
1128
2631
|
const block = blocks[i];
|
|
2632
|
+
const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
|
|
2633
|
+
// Word normally lets a same-box continuous section begin on its predecessor's page. By
|
|
2634
|
+
// default, a footnote reference before that boundary promotes it to a page break. The
|
|
2635
|
+
// footnoteLayoutLikeWW8 compatibility switch permits post-break paragraphs without their own
|
|
2636
|
+
// references to remain on the shared page; keep checking until the page turns so a later
|
|
2637
|
+
// referenced paragraph still begins on a fresh page.
|
|
2638
|
+
if (block.sectionIndex !== pageSectionIndex && currentPageHasFootnoteReference
|
|
2639
|
+
&& (!this.footnoteLayoutLikeWord8 || allBlockFootnoteIds.length > 0)) {
|
|
2640
|
+
finishPage(block.sectionIndex);
|
|
2641
|
+
}
|
|
2642
|
+
const pageIsEmpty = currentContent.length === 0
|
|
2643
|
+
&& currentFootnoteIds.length === 0
|
|
2644
|
+
&& (currentContinuation?.remainingElements.length ?? 0) === 0;
|
|
2645
|
+
if (pageIsEmpty && block.sectionIndex !== pageSectionIndex) {
|
|
2646
|
+
adoptEmptyPageOwner(block.sectionIndex);
|
|
2647
|
+
}
|
|
2648
|
+
prepareCurrentContinuation();
|
|
2649
|
+
if (currentFootnoteHeight > 0) {
|
|
2650
|
+
const ownerDimensions = dimensionsFor();
|
|
2651
|
+
const ownerBands = this.getPageBands(ownerDimensions, pageSectionIndex, pageInSection(), displayedPageNumber());
|
|
2652
|
+
const ownerMaxFootnoteArea = ownerBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO;
|
|
2653
|
+
if (currentFootnoteHeight > ownerMaxFootnoteArea) {
|
|
2654
|
+
// A queue of whole notes larger than the band does not entitle it to
|
|
2655
|
+
// the whole page: the band is capped and the rest of the page still
|
|
2656
|
+
// belongs to body text. Keep the notes that fit here, defer the rest,
|
|
2657
|
+
// and only fall back to a note-owned page when not even one note fits
|
|
2658
|
+
// — that case needs the splitting finishPage performs.
|
|
2659
|
+
const fittingIds = [];
|
|
2660
|
+
let fittingHeight = 0;
|
|
2661
|
+
if ((currentContinuation?.remainingElements.length ?? 0) === 0
|
|
2662
|
+
&& currentPartialFootnotes.length === 0) {
|
|
2663
|
+
for (const footnoteId of currentFootnoteIds) {
|
|
2664
|
+
this.checkpoint();
|
|
2665
|
+
const candidateHeight = this.measureFootnotesHeight([...fittingIds, footnoteId], ownerDimensions.contentWidth);
|
|
2666
|
+
if (candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT > ownerMaxFootnoteArea)
|
|
2667
|
+
break;
|
|
2668
|
+
fittingIds.push(footnoteId);
|
|
2669
|
+
fittingHeight = candidateHeight;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
if (fittingIds.length > 0) {
|
|
2673
|
+
deferredFootnoteIds = [
|
|
2674
|
+
...currentFootnoteIds.slice(fittingIds.length),
|
|
2675
|
+
...deferredFootnoteIds,
|
|
2676
|
+
];
|
|
2677
|
+
currentFootnoteIds = fittingIds;
|
|
2678
|
+
currentFootnoteHeight = fittingHeight;
|
|
2679
|
+
}
|
|
2680
|
+
else {
|
|
2681
|
+
finishPage(block.sectionIndex);
|
|
2682
|
+
i--;
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
const blockDimensions = dimensionsFor(block.sectionIndex);
|
|
2688
|
+
const nextBlockPageInSection = pageInSection(block.sectionIndex, pageNumber + 1);
|
|
2689
|
+
const freshBlockPageBodyHeight = this.getPageBands(blockDimensions, block.sectionIndex, nextBlockPageInSection, displayedPageNumber(block.sectionIndex, nextBlockPageInSection, pageNumber + 1)).bodyHeight;
|
|
1129
2690
|
// Handle explicit page breaks
|
|
1130
2691
|
if (block.isPageBreak) {
|
|
1131
|
-
finishPage();
|
|
2692
|
+
finishPage(block.sectionIndex);
|
|
1132
2693
|
continue;
|
|
1133
2694
|
}
|
|
1134
2695
|
// Handle page break before
|
|
1135
2696
|
if (block.pageBreakBefore && currentContent.length > 0) {
|
|
1136
|
-
finishPage();
|
|
2697
|
+
finishPage(block.sectionIndex);
|
|
1137
2698
|
}
|
|
1138
2699
|
// A series of keep-with-next blocks is one indivisible placement unit
|
|
1139
2700
|
// when it can fit a new page. The former one-block lookahead was never
|
|
@@ -1155,47 +2716,43 @@ export class PaginationEngine {
|
|
|
1155
2716
|
const newChainFootnoteIds = this.collectNewFootnoteIds(keepChain, currentFootnoteIds);
|
|
1156
2717
|
let additionalChainFootnoteHeight = 0;
|
|
1157
2718
|
if (newChainFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
|
|
1158
|
-
const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds],
|
|
2719
|
+
const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds], blockDimensions.contentWidth, currentContinuation);
|
|
1159
2720
|
additionalChainFootnoteHeight = Math.max(0, totalChainFootnoteHeight - currentFootnoteHeight);
|
|
1160
2721
|
}
|
|
1161
|
-
const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0) +
|
|
2722
|
+
const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0, pageInSection(block.sectionIndex)) +
|
|
1162
2723
|
additionalChainFootnoteHeight;
|
|
1163
2724
|
const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
|
|
1164
2725
|
if (currentChainHeight > currentAvailableHeight) {
|
|
1165
|
-
const nextPageBands = this.getPageBands(
|
|
1166
|
-
const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true);
|
|
2726
|
+
const nextPageBands = this.getPageBands(dimensionsFor(block.sectionIndex), block.sectionIndex, pageInSection(block.sectionIndex, pageNumber + 1), displayedPageNumber(block.sectionIndex, pageInSection(block.sectionIndex, pageNumber + 1), pageNumber + 1));
|
|
2727
|
+
const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true, pageInSection(block.sectionIndex, pageNumber + 1));
|
|
1167
2728
|
// finishPage transfers this continuation to the new page's
|
|
1168
2729
|
// currentContinuation state, so include it in the destination
|
|
1169
2730
|
// page's footnote reservation before deciding to move the chain.
|
|
1170
|
-
const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds,
|
|
2731
|
+
const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds, blockDimensions.contentWidth, nextPageContinuation);
|
|
1171
2732
|
if (freshChainBodyHeight + freshChainFootnoteHeight <=
|
|
1172
2733
|
nextPageBands.bodyHeight) {
|
|
1173
|
-
finishPage();
|
|
2734
|
+
finishPage(block.sectionIndex);
|
|
1174
2735
|
}
|
|
1175
2736
|
}
|
|
1176
2737
|
}
|
|
1177
2738
|
}
|
|
1178
2739
|
// Extract footnote references from this block
|
|
1179
|
-
const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
|
|
1180
2740
|
// Only count new footnotes (not already on this page)
|
|
1181
2741
|
const newFootnoteIds = this.collectNewFootnoteIds([block], currentFootnoteIds);
|
|
1182
2742
|
// Calculate additional footnote height if this block is added
|
|
2743
|
+
let combinedFootnoteHeight = currentFootnoteHeight;
|
|
1183
2744
|
let additionalFootnoteHeight = 0;
|
|
1184
2745
|
if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
|
|
1185
2746
|
// Measure the combined height of all footnotes that would be on this page
|
|
1186
2747
|
// (including any continuation)
|
|
1187
2748
|
const combinedFootnoteIds = [...currentFootnoteIds, ...newFootnoteIds];
|
|
1188
|
-
|
|
1189
|
-
additionalFootnoteHeight =
|
|
2749
|
+
combinedFootnoteHeight = this.measureFootnotesHeight(combinedFootnoteIds, blockDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
|
|
2750
|
+
additionalFootnoteHeight = Math.max(0, combinedFootnoteHeight - currentFootnoteHeight);
|
|
1190
2751
|
}
|
|
1191
2752
|
// Calculate the effective height this block will consume
|
|
1192
2753
|
// Account for margin collapsing: the gap between blocks is max(prevBottom, currTop), not sum
|
|
1193
2754
|
const isFirstOnPage = currentContent.length === 0;
|
|
1194
|
-
|
|
1195
|
-
if (!isFirstOnPage) {
|
|
1196
|
-
// Margin collapsing: use the larger of the two adjacent margins
|
|
1197
|
-
effectiveMarginTop = Math.max(block.marginTopPt, prevMarginBottomPt) - prevMarginBottomPt;
|
|
1198
|
-
}
|
|
2755
|
+
const effectiveMarginTop = this.effectiveBlockMarginTop(block, prevMarginBottomPt, isFirstOnPage, pageInSection(block.sectionIndex));
|
|
1199
2756
|
// Visible height = top margin gap + content + footnote space
|
|
1200
2757
|
// Note: bottom margin is NOT included in the fit check because the last block's
|
|
1201
2758
|
// bottom margin extends beyond the content area and is clipped by overflow:hidden.
|
|
@@ -1206,13 +2763,12 @@ export class PaginationEngine {
|
|
|
1206
2763
|
// Calculate maximum footnote area for this page (can expand into body content space)
|
|
1207
2764
|
const bodyContentUsed = effectiveContentHeight - remainingHeight;
|
|
1208
2765
|
const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
|
|
1209
|
-
const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
|
|
1210
2766
|
// A paragraph that cannot fit as a whole may still have a simple text-only
|
|
1211
2767
|
// prefix that fits this page. Fragment before the ordinary next-page or
|
|
1212
2768
|
// oversized fallback so the cloned head participates in the same margin
|
|
1213
2769
|
// and footnote accounting as every other block.
|
|
1214
2770
|
if (blockSpace > effectiveRemainingHeight) {
|
|
1215
|
-
const paragraphFragments = this.tryFragmentParagraph(block,
|
|
2771
|
+
const paragraphFragments = this.tryFragmentParagraph(block, blockDimensions, effectiveRemainingHeight, effectiveMarginTop);
|
|
1216
2772
|
if (paragraphFragments) {
|
|
1217
2773
|
blocks.splice(i, 1, ...paragraphFragments);
|
|
1218
2774
|
i--;
|
|
@@ -1220,48 +2776,69 @@ export class PaginationEngine {
|
|
|
1220
2776
|
}
|
|
1221
2777
|
}
|
|
1222
2778
|
// Check if block fits on current page (including its footnotes)
|
|
1223
|
-
if (blockSpace <= effectiveRemainingHeight
|
|
2779
|
+
if (blockSpace <= effectiveRemainingHeight
|
|
2780
|
+
&& (combinedFootnoteHeight === 0
|
|
2781
|
+
|| combinedFootnoteHeight + FOOTNOTE_MEASUREMENT_GUARD_PT <= maxFootnoteArea)) {
|
|
1224
2782
|
// Block fits with current footnote allocation
|
|
1225
|
-
|
|
2783
|
+
markSectionPlaced(block.sectionIndex);
|
|
2784
|
+
currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection(block.sectionIndex)));
|
|
1226
2785
|
remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt);
|
|
1227
2786
|
prevMarginBottomPt = block.marginBottomPt;
|
|
1228
2787
|
// Add new footnotes to current page
|
|
1229
2788
|
if (newFootnoteIds.length > 0) {
|
|
1230
2789
|
currentFootnoteIds.push(...newFootnoteIds);
|
|
1231
|
-
currentFootnoteHeight
|
|
2790
|
+
currentFootnoteHeight = combinedFootnoteHeight;
|
|
1232
2791
|
}
|
|
2792
|
+
currentPageHasFootnoteReference || (currentPageHasFootnoteReference = allBlockFootnoteIds.length > 0);
|
|
1233
2793
|
}
|
|
1234
|
-
else if (block.heightPt + block.
|
|
2794
|
+
else if (block.heightPt + this.effectiveBlockMarginTop(block, 0, true, pageInSection(block.sectionIndex, pageNumber + 1)) <= freshBlockPageBodyHeight) {
|
|
1235
2795
|
// Block doesn't fit with current allocation - try expanding footnote area
|
|
1236
2796
|
const blockSpaceWithoutFootnotes = effectiveMarginTop + block.heightPt;
|
|
1237
|
-
// Check if block fits if we expand footnote area
|
|
1238
|
-
// We can expand footnotes up to maxFootnoteArea, leaving room for body content
|
|
1239
|
-
const minBodySpaceNeeded = bodyContentUsed + blockSpaceWithoutFootnotes + MIN_BODY_CONTENT_HEIGHT;
|
|
1240
|
-
const canExpandFootnotes = minBodySpaceNeeded <= effectiveContentHeight;
|
|
1241
2797
|
if (newFootnoteIds.length > 0 && blockSpaceWithoutFootnotes <= effectiveRemainingHeight) {
|
|
1242
2798
|
// Block itself fits, but footnotes don't - expand footnote area
|
|
1243
|
-
|
|
2799
|
+
markSectionPlaced(block.sectionIndex);
|
|
2800
|
+
currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection(block.sectionIndex)));
|
|
1244
2801
|
remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt);
|
|
1245
2802
|
prevMarginBottomPt = block.marginBottomPt;
|
|
1246
2803
|
// Calculate EXPANDED space available for footnotes
|
|
1247
2804
|
// Footnotes can take up to maxFootnoteArea or all remaining space, whichever is less
|
|
1248
2805
|
const availableForFootnotes = Math.min(maxFootnoteArea, effectiveContentHeight - bodyContentUsed - blockSpaceWithoutFootnotes);
|
|
2806
|
+
// Deferring a note costs a page turn, and it only buys anything if the
|
|
2807
|
+
// next page's band is larger than what this page can already offer.
|
|
2808
|
+
// When this page already offers the maximum band, a note that cannot
|
|
2809
|
+
// start here cannot start there either: keep the established clipped
|
|
2810
|
+
// fallback on the citing page rather than evacuating it.
|
|
2811
|
+
const nextPageMaxFootnoteArea = freshBlockPageBodyHeight * MAX_FOOTNOTE_AREA_RATIO;
|
|
2812
|
+
const deferralCannotHelp = availableForFootnotes >= nextPageMaxFootnoteArea;
|
|
1249
2813
|
// Try to fit as much of each new footnote as possible in expanded area
|
|
1250
|
-
for (
|
|
2814
|
+
for (let noteIndex = 0; noteIndex < newFootnoteIds.length; noteIndex++) {
|
|
2815
|
+
this.checkpoint();
|
|
2816
|
+
const footnoteId = newFootnoteIds[noteIndex];
|
|
1251
2817
|
const footnote = this.footnoteRegistry.get(footnoteId);
|
|
1252
2818
|
if (!footnote)
|
|
1253
2819
|
continue;
|
|
1254
|
-
|
|
2820
|
+
// One page can carry only one split tail. Once that slot is used,
|
|
2821
|
+
// keep every later note in source order on the deferral queue.
|
|
2822
|
+
if (nextPageContinuation) {
|
|
2823
|
+
deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
|
|
2824
|
+
break;
|
|
2825
|
+
}
|
|
2826
|
+
const candidateIds = [...currentFootnoteIds, footnoteId];
|
|
2827
|
+
const candidateHeight = this.measureFootnotesHeight(candidateIds, blockDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
|
|
1255
2828
|
const spaceLeftForFootnotes = availableForFootnotes - currentFootnoteHeight;
|
|
1256
|
-
if (
|
|
2829
|
+
if (candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT <= availableForFootnotes) {
|
|
1257
2830
|
// Whole footnote fits in expanded area
|
|
1258
2831
|
currentFootnoteIds.push(footnoteId);
|
|
1259
|
-
currentFootnoteHeight
|
|
2832
|
+
currentFootnoteHeight = candidateHeight;
|
|
1260
2833
|
}
|
|
1261
2834
|
else {
|
|
1262
2835
|
// Footnote needs to be split - use all available expanded space
|
|
1263
2836
|
if (spaceLeftForFootnotes > 20) { // Minimum space to start a footnote
|
|
1264
|
-
const { fits, overflow } = this.splitFootnoteToFit(footnote,
|
|
2837
|
+
const { fits, overflow } = this.splitFootnoteToFit(footnote, availableForFootnotes, blockDimensions.contentWidth, deferralCannotHelp, {
|
|
2838
|
+
footnoteIds: currentFootnoteIds,
|
|
2839
|
+
continuation: currentContinuation,
|
|
2840
|
+
partialFootnotes: currentPartialFootnotes,
|
|
2841
|
+
});
|
|
1265
2842
|
if (fits.length > 0) {
|
|
1266
2843
|
// Add partial footnote to current page
|
|
1267
2844
|
currentFootnoteIds.push(footnoteId);
|
|
@@ -1272,87 +2849,48 @@ export class PaginationEngine {
|
|
|
1272
2849
|
if (overflow.length > 0) {
|
|
1273
2850
|
nextPageContinuation = {
|
|
1274
2851
|
footnoteId,
|
|
2852
|
+
sourceAnchorId: footnote.dataset.sourceAnchorId,
|
|
1275
2853
|
remainingElements: overflow
|
|
1276
2854
|
};
|
|
1277
2855
|
}
|
|
1278
|
-
currentFootnoteHeight =
|
|
2856
|
+
currentFootnoteHeight = this.measureFootnotesHeight(currentFootnoteIds, blockDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
|
|
2857
|
+
if (overflow.length > 0) {
|
|
2858
|
+
deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex + 1));
|
|
2859
|
+
break;
|
|
2860
|
+
}
|
|
1279
2861
|
}
|
|
1280
2862
|
else {
|
|
1281
2863
|
// Nothing of this note fits: defer the WHOLE note rather than assigning the
|
|
1282
2864
|
// single continuation slot, which a later note on this page would overwrite.
|
|
1283
|
-
deferredFootnoteIds.push(
|
|
2865
|
+
deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
|
|
2866
|
+
break;
|
|
1284
2867
|
}
|
|
1285
2868
|
}
|
|
1286
2869
|
else {
|
|
1287
2870
|
// Not enough space to even start the note — same deferral.
|
|
1288
|
-
deferredFootnoteIds.push(
|
|
2871
|
+
deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
|
|
2872
|
+
break;
|
|
1289
2873
|
}
|
|
1290
2874
|
}
|
|
1291
2875
|
}
|
|
1292
|
-
|
|
1293
|
-
else if (canExpandFootnotes && newFootnoteIds.length > 0) {
|
|
1294
|
-
// Block doesn't fit with current layout, but might fit if we expand footnote area first
|
|
1295
|
-
// This handles the case where we need to give footnotes more space BEFORE adding the block
|
|
1296
|
-
// First, try to fit more of current footnotes by expanding the area
|
|
1297
|
-
// Then check if the block fits in reduced body space
|
|
1298
|
-
const expandedFootnoteSpace = Math.min(maxFootnoteArea, additionalFootnoteHeight + currentFootnoteHeight);
|
|
1299
|
-
const bodySpaceAfterExpansion = effectiveContentHeight - expandedFootnoteSpace;
|
|
1300
|
-
if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) {
|
|
1301
|
-
// Block fits after expanding footnote area.
|
|
1302
|
-
currentContent.push(block.element.cloneNode(true));
|
|
1303
|
-
// `remainingHeight` tracks BODY consumption only — every other branch maintains it
|
|
1304
|
-
// that way, and the footnote reserve is applied separately via `effectiveRemainingHeight`
|
|
1305
|
-
// at the top of each iteration. Assigning `bodySpaceAfterExpansion - …` here folded the
|
|
1306
|
-
// reserve in a second time, so a later block would see a body budget short by the whole
|
|
1307
|
-
// footnote area. Consistency fix: no measurable difference on the documents tested, but
|
|
1308
|
-
// the two meanings must not coexist or the next change here inherits a latent bug.
|
|
1309
|
-
remainingHeight = bodySpaceAfterExpansion - bodyContentUsed - blockSpaceWithoutFootnotes;
|
|
1310
|
-
prevMarginBottomPt = block.marginBottomPt;
|
|
1311
|
-
currentFootnoteIds.push(...newFootnoteIds);
|
|
1312
|
-
currentFootnoteHeight = expandedFootnoteSpace;
|
|
1313
|
-
}
|
|
1314
|
-
else {
|
|
1315
|
-
// Still doesn't fit - start new page
|
|
1316
|
-
finishPage();
|
|
1317
|
-
const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
|
|
1318
|
-
? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
|
|
1319
|
-
: (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
|
|
1320
|
-
const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
|
|
1321
|
-
currentContent.push(block.element.cloneNode(true));
|
|
1322
|
-
remainingHeight = effectiveContentHeight - newPageSpace;
|
|
1323
|
-
prevMarginBottomPt = block.marginBottomPt;
|
|
1324
|
-
// Merge, never replace: finishPage() may have just seeded this page with notes deferred
|
|
1325
|
-
// from the previous one, and overwriting here dropped them from the document.
|
|
1326
|
-
currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
|
|
1327
|
-
currentFootnoteHeight = newPageFootnoteHeight;
|
|
1328
|
-
}
|
|
2876
|
+
currentPageHasFootnoteReference || (currentPageHasFootnoteReference = allBlockFootnoteIds.length > 0);
|
|
1329
2877
|
}
|
|
1330
2878
|
else {
|
|
1331
|
-
// Block itself doesn't fit
|
|
1332
|
-
|
|
1333
|
-
//
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
: (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
|
|
1338
|
-
// Include full top margin
|
|
1339
|
-
const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
|
|
1340
|
-
currentContent.push(block.element.cloneNode(true));
|
|
1341
|
-
remainingHeight = effectiveContentHeight - newPageSpace;
|
|
1342
|
-
prevMarginBottomPt = block.marginBottomPt;
|
|
1343
|
-
// Merge, never replace: finishPage() may have just seeded this page with notes deferred
|
|
1344
|
-
// from the previous one, and overwriting here dropped them from the document.
|
|
1345
|
-
currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
|
|
1346
|
-
currentFootnoteHeight = newPageFootnoteHeight;
|
|
2879
|
+
// Block itself doesn't fit. Retry after the page turn so carried
|
|
2880
|
+
// continuations and deferred notes participate in the normal fit and
|
|
2881
|
+
// split decisions instead of being overwritten by forced placement.
|
|
2882
|
+
finishPage(block.sectionIndex, !pageHasPayload());
|
|
2883
|
+
i--;
|
|
2884
|
+
continue;
|
|
1347
2885
|
}
|
|
1348
2886
|
}
|
|
1349
2887
|
else {
|
|
1350
2888
|
// Block is taller than a page. Ordinary tables can be split at complete
|
|
1351
2889
|
// row boundaries; every other block retains the established overflow path.
|
|
1352
|
-
const tableFragments = this.trySplitSimpleOversizedTable(block,
|
|
2890
|
+
const tableFragments = this.trySplitSimpleOversizedTable(block, dimensionsFor(block.sectionIndex), block.sectionIndex);
|
|
1353
2891
|
if (tableFragments) {
|
|
1354
2892
|
if (currentContent.length > 0 || currentContinuation) {
|
|
1355
|
-
finishPage();
|
|
2893
|
+
finishPage(block.sectionIndex);
|
|
1356
2894
|
}
|
|
1357
2895
|
blocks.splice(i, 1, ...tableFragments);
|
|
1358
2896
|
i--;
|
|
@@ -1361,18 +2899,30 @@ export class PaginationEngine {
|
|
|
1361
2899
|
// Unsupported oversized blocks are intentionally left intact. Splitting
|
|
1362
2900
|
// arbitrary HTML, merged tables, or footnote-bearing tables would be less
|
|
1363
2901
|
// correct than the prior clipped fallback.
|
|
1364
|
-
if (
|
|
1365
|
-
finishPage();
|
|
2902
|
+
if (pageHasPayload()) {
|
|
2903
|
+
finishPage(block.sectionIndex);
|
|
2904
|
+
i--;
|
|
2905
|
+
continue;
|
|
1366
2906
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
//
|
|
1370
|
-
|
|
1371
|
-
|
|
2907
|
+
markSectionPlaced(block.sectionIndex);
|
|
2908
|
+
currentContent.push(this.cloneBlockForPage(block, true, pageInSection(block.sectionIndex)));
|
|
2909
|
+
// An unsupported body block already occupies/clips its whole body band.
|
|
2910
|
+
// Preserve its notes losslessly on following note-only pages rather
|
|
2911
|
+
// than drawing them underneath that clipped fallback.
|
|
2912
|
+
deferredFootnoteIds.push(...newFootnoteIds);
|
|
2913
|
+
currentPageHasFootnoteReference || (currentPageHasFootnoteReference = allBlockFootnoteIds.length > 0);
|
|
2914
|
+
finishPage(block.sectionIndex);
|
|
1372
2915
|
}
|
|
1373
2916
|
}
|
|
1374
2917
|
// Finish last page
|
|
1375
2918
|
finishPage();
|
|
2919
|
+
// A split created while finishing the final body page still needs a page substrate.
|
|
2920
|
+
// Drain all remaining note paragraphs into footnote-only continuation pages.
|
|
2921
|
+
while (currentFootnoteIds.length > 0
|
|
2922
|
+
|| deferredFootnoteIds.length > 0
|
|
2923
|
+
|| (currentContinuation?.remainingElements.length ?? 0) > 0) {
|
|
2924
|
+
finishPage();
|
|
2925
|
+
}
|
|
1376
2926
|
// Store any remaining continuation for next section
|
|
1377
2927
|
this.pendingFootnoteContinuation = nextPageContinuation;
|
|
1378
2928
|
return pages;
|
|
@@ -1399,6 +2949,19 @@ export class PaginationEngine {
|
|
|
1399
2949
|
el.setAttribute("contenteditable", "false");
|
|
1400
2950
|
}
|
|
1401
2951
|
}
|
|
2952
|
+
/** A repeated margin note is presentation, not a second bookmark/link target. */
|
|
2953
|
+
makeClonedMarginCommentInert(root) {
|
|
2954
|
+
this.makeClonedStoryInert(root);
|
|
2955
|
+
const nodes = [root, ...Array.from(root.querySelectorAll("*"))];
|
|
2956
|
+
for (const element of nodes) {
|
|
2957
|
+
element.removeAttribute("id");
|
|
2958
|
+
if (element.localName === "a" && element.getAttribute("href")?.startsWith("#")) {
|
|
2959
|
+
element.removeAttribute("href");
|
|
2960
|
+
element.setAttribute("aria-disabled", "true");
|
|
2961
|
+
element.tabIndex = -1;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
1402
2965
|
/**
|
|
1403
2966
|
* Resolves floating DrawingML objects after their anchor paragraphs have landed on a page.
|
|
1404
2967
|
*
|
|
@@ -1425,7 +2988,7 @@ export class PaginationEngine {
|
|
|
1425
2988
|
staticLeft: toPageX(staticRect.left),
|
|
1426
2989
|
staticTop: toPageY(staticRect.top),
|
|
1427
2990
|
lineHeight: paragraph
|
|
1428
|
-
? (parseFloat(getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint
|
|
2991
|
+
? (parseFloat(this.view.getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint
|
|
1429
2992
|
: staticRect.height / pixelsPerPoint,
|
|
1430
2993
|
paragraphLeft: toPageX(paragraphRect.left),
|
|
1431
2994
|
paragraphTop: toPageY(paragraphRect.top),
|
|
@@ -1537,13 +3100,21 @@ export class PaginationEngine {
|
|
|
1537
3100
|
fraction = pageNumber % 2 === 1 ? 1 : 0;
|
|
1538
3101
|
return reference.start + (reference.size - objectSize) * fraction;
|
|
1539
3102
|
}
|
|
3103
|
+
/** Charge a physical page before any page-owned partitioning or DOM allocation. */
|
|
3104
|
+
admitPageAllocation() {
|
|
3105
|
+
const prospectivePageCount = this.createdPageCount + 1;
|
|
3106
|
+
this.pageCountCheckpoint?.(prospectivePageCount);
|
|
3107
|
+
this.createdPageCount = prospectivePageCount;
|
|
3108
|
+
}
|
|
1540
3109
|
/**
|
|
1541
3110
|
* Creates a page container element.
|
|
1542
3111
|
*/
|
|
1543
|
-
createPage(dims, pageNumber, sectionIndex, content, pageInSection, footnoteIds = [], footnoteHeight = 0, continuation, partialFootnotes) {
|
|
3112
|
+
createPage(dims, pageNumber, sectionIndex, displayedPageNumber, content, pageInSection, footnoteIds = [], footnoteHeight = 0, continuation, partialFootnotes, isSectionFiller = false, pageAlreadyAdmitted = false) {
|
|
3113
|
+
if (!pageAlreadyAdmitted)
|
|
3114
|
+
this.admitPageAllocation();
|
|
1544
3115
|
// Create page box at full size, then scale the entire box
|
|
1545
3116
|
// This ensures proper clipping and consistent scaling of all elements
|
|
1546
|
-
const pageBox = document.createElement("div");
|
|
3117
|
+
const pageBox = this.document.createElement("div");
|
|
1547
3118
|
pageBox.className = `${this.cssPrefix}box`;
|
|
1548
3119
|
pageBox.style.width = `${dims.pageWidth}pt`;
|
|
1549
3120
|
pageBox.style.height = `${dims.pageHeight}pt`;
|
|
@@ -1553,35 +3124,40 @@ export class PaginationEngine {
|
|
|
1553
3124
|
// Zoom affects layout (no negative margin hack needed) and renders text more crisply
|
|
1554
3125
|
// Note: zoom is non-standard but supported in all major browsers
|
|
1555
3126
|
if (this.scale !== 1) {
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
3127
|
+
if (this.view.CSS?.supports("zoom", "1")) {
|
|
3128
|
+
pageBox.style.zoom = String(this.scale);
|
|
3129
|
+
}
|
|
3130
|
+
else {
|
|
3131
|
+
pageBox.style.transform = `scale(${this.scale})`;
|
|
3132
|
+
pageBox.style.transformOrigin = "top left";
|
|
3133
|
+
// Transform does not affect layout, so compensate for the natural box dimensions.
|
|
3134
|
+
const heightReductionPt = dims.pageHeight * (1 - this.scale);
|
|
3135
|
+
const widthReductionPt = dims.pageWidth * (1 - this.scale);
|
|
3136
|
+
const heightReductionPx = ptToPx(heightReductionPt);
|
|
3137
|
+
const widthReductionPx = ptToPx(widthReductionPt);
|
|
3138
|
+
pageBox.style.marginRight = `-${widthReductionPx}px`;
|
|
3139
|
+
pageBox.style.marginBottom = `${this.pageGap - heightReductionPx}px`;
|
|
3140
|
+
}
|
|
1570
3141
|
}
|
|
1571
3142
|
// Hint browser for GPU compositing and layout isolation
|
|
1572
3143
|
pageBox.style.willChange = "transform";
|
|
1573
3144
|
pageBox.style.contain = "layout paint";
|
|
1574
3145
|
pageBox.dataset.pageNumber = String(pageNumber);
|
|
1575
3146
|
pageBox.dataset.sectionIndex = String(sectionIndex);
|
|
3147
|
+
pageBox.dataset.displayedPageNumber = String(displayedPageNumber);
|
|
3148
|
+
if (isSectionFiller)
|
|
3149
|
+
pageBox.dataset.sectionFiller = "true";
|
|
1576
3150
|
// Needed by substitutePageNumberFields: a section that restarts numbering counts from its own
|
|
1577
3151
|
// first page, not from the document's.
|
|
1578
3152
|
pageBox.dataset.pageInSection = String(pageInSection);
|
|
1579
3153
|
// Where the three bands sit on this page (no re-measurement needed)
|
|
1580
|
-
const bands = this.getPageBands(dims, sectionIndex, pageInSection,
|
|
3154
|
+
const bands = this.getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber);
|
|
1581
3155
|
// Add header if available for this section/page
|
|
1582
|
-
const headerSource =
|
|
3156
|
+
const headerSource = isSectionFiller
|
|
3157
|
+
? undefined
|
|
3158
|
+
: this.selectHeader(sectionIndex, pageInSection, displayedPageNumber);
|
|
1583
3159
|
if (headerSource) {
|
|
1584
|
-
const headerDiv = document.createElement("div");
|
|
3160
|
+
const headerDiv = this.document.createElement("div");
|
|
1585
3161
|
headerDiv.className = `${this.cssPrefix}header`;
|
|
1586
3162
|
headerDiv.style.position = "absolute";
|
|
1587
3163
|
// `w:header` is the distance to the TOP of the story, and the story grows downward from
|
|
@@ -1609,7 +3185,7 @@ export class PaginationEngine {
|
|
|
1609
3185
|
// Create content area using pre-computed effective heights
|
|
1610
3186
|
const contentAreaTop = bands.bodyTop;
|
|
1611
3187
|
const contentAreaHeight = bands.bodyHeight;
|
|
1612
|
-
const contentArea = document.createElement("div");
|
|
3188
|
+
const contentArea = this.document.createElement("div");
|
|
1613
3189
|
contentArea.className = `${this.cssPrefix}content`;
|
|
1614
3190
|
contentArea.style.position = "absolute";
|
|
1615
3191
|
contentArea.style.top = `${contentAreaTop}pt`;
|
|
@@ -1624,13 +3200,53 @@ export class PaginationEngine {
|
|
|
1624
3200
|
pageBox.appendChild(contentArea);
|
|
1625
3201
|
// Add footnotes if any references appear on this page (or continuation from previous)
|
|
1626
3202
|
const hasContinuation = continuation && continuation.remainingElements.length > 0;
|
|
1627
|
-
if (footnoteIds.length > 0 || hasContinuation) {
|
|
3203
|
+
if (!isSectionFiller && (footnoteIds.length > 0 || hasContinuation)) {
|
|
1628
3204
|
this.addPageFootnotes(pageBox, footnoteIds, dims, bands, footnoteHeight, continuation, partialFootnotes);
|
|
1629
3205
|
}
|
|
3206
|
+
// Materialize margin comments after footnotes exist so markers inside a
|
|
3207
|
+
// note participate in the same page-owned comment story as body markers.
|
|
3208
|
+
const pageCommentIds = [];
|
|
3209
|
+
for (const marker of Array.from(pageBox.querySelectorAll("[data-comment-id]"))) {
|
|
3210
|
+
if (marker.closest(`.${this.cssPrefix}comment-margin`))
|
|
3211
|
+
continue;
|
|
3212
|
+
const id = marker.dataset.commentId;
|
|
3213
|
+
if (id && this.commentMarginRegistry.has(id) && !pageCommentIds.includes(id)) {
|
|
3214
|
+
pageCommentIds.push(id);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
if (pageCommentIds.length > 0) {
|
|
3218
|
+
const marginColumn = this.document.createElement("aside");
|
|
3219
|
+
marginColumn.className = `${this.cssPrefix}comment-margin`;
|
|
3220
|
+
marginColumn.style.position = "absolute";
|
|
3221
|
+
marginColumn.style.top = `${contentAreaTop}pt`;
|
|
3222
|
+
marginColumn.style.left = `${dims.marginLeft + dims.contentWidth + 3}pt`;
|
|
3223
|
+
marginColumn.style.width = `${Math.max(12, dims.marginRight - 6)}pt`;
|
|
3224
|
+
marginColumn.style.maxHeight = `${contentAreaHeight}pt`;
|
|
3225
|
+
marginColumn.style.overflow = "hidden";
|
|
3226
|
+
marginColumn.style.boxSizing = "border-box";
|
|
3227
|
+
const placedThreadRoots = new Set();
|
|
3228
|
+
for (const id of pageCommentIds) {
|
|
3229
|
+
const source = this.commentMarginRegistry.get(id);
|
|
3230
|
+
if (source) {
|
|
3231
|
+
// Every id in a nested thread maps to the same root note; place it once even
|
|
3232
|
+
// when the root's and a reply's markers share the page.
|
|
3233
|
+
const rootId = source.dataset.commentId ?? id;
|
|
3234
|
+
if (placedThreadRoots.has(rootId))
|
|
3235
|
+
continue;
|
|
3236
|
+
placedThreadRoots.add(rootId);
|
|
3237
|
+
const clone = source.cloneNode(true);
|
|
3238
|
+
this.makeClonedMarginCommentInert(clone);
|
|
3239
|
+
marginColumn.appendChild(clone);
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
pageBox.appendChild(marginColumn);
|
|
3243
|
+
}
|
|
1630
3244
|
// Add footer if available for this section/page
|
|
1631
|
-
const footerSource =
|
|
3245
|
+
const footerSource = isSectionFiller
|
|
3246
|
+
? undefined
|
|
3247
|
+
: this.selectFooter(sectionIndex, pageInSection, displayedPageNumber);
|
|
1632
3248
|
if (footerSource) {
|
|
1633
|
-
const footerDiv = document.createElement("div");
|
|
3249
|
+
const footerDiv = this.document.createElement("div");
|
|
1634
3250
|
footerDiv.className = `${this.cssPrefix}footer`;
|
|
1635
3251
|
footerDiv.style.position = "absolute";
|
|
1636
3252
|
// `w:footer` is the distance to the BOTTOM of the story, and the story grows upward from
|
|
@@ -1655,8 +3271,8 @@ export class PaginationEngine {
|
|
|
1655
3271
|
pageBox.appendChild(footerDiv);
|
|
1656
3272
|
}
|
|
1657
3273
|
// Add page number (will be hidden by CSS if document has footer)
|
|
1658
|
-
if (this.showPageNumbers) {
|
|
1659
|
-
const pageNum = document.createElement("div");
|
|
3274
|
+
if (this.showPageNumbers && !isSectionFiller) {
|
|
3275
|
+
const pageNum = this.document.createElement("div");
|
|
1660
3276
|
pageNum.className = `${this.cssPrefix}number`;
|
|
1661
3277
|
pageNum.textContent = String(pageNumber);
|
|
1662
3278
|
pageBox.appendChild(pageNum);
|
|
@@ -1719,8 +3335,9 @@ export class PaginationEngine {
|
|
|
1719
3335
|
* ```
|
|
1720
3336
|
*/
|
|
1721
3337
|
export function paginateHtml(html, container, options = {}) {
|
|
3338
|
+
const ownerDocument = typeof container === "string" ? globalThis.document : container.ownerDocument;
|
|
1722
3339
|
const containerEl = typeof container === "string"
|
|
1723
|
-
?
|
|
3340
|
+
? ownerDocument.getElementById(container)
|
|
1724
3341
|
: container;
|
|
1725
3342
|
if (!containerEl) {
|
|
1726
3343
|
throw new Error("Container element not found");
|