docxodus 9.9.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 +1914 -419
- 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 +2803 -469
- package/dist/embed.iife.js +2801 -467
- 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 +1827 -410
- package/dist/pagination.d.ts +249 -15
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +1942 -453
- 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 +45 -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.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.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.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.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.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/dotnet.boot.js +21 -21
- package/dist/wasm/_framework/dotnet.boot.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/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 +22 -6
package/dist/pagination.js
CHANGED
|
@@ -19,12 +19,111 @@ function samePageBox(a, b) {
|
|
|
19
19
|
a.marginBottom === b.marginBottom &&
|
|
20
20
|
a.marginLeft === b.marginLeft);
|
|
21
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
|
+
}
|
|
22
118
|
// Default letter size in points (612 x 792 = 8.5" x 11")
|
|
23
119
|
// Maximum percentage of content height that footnotes can occupy
|
|
24
120
|
// This allows footnotes to expand upward into body content space when needed
|
|
25
121
|
const MAX_FOOTNOTE_AREA_RATIO = 0.6; // 60% of content height
|
|
26
|
-
//
|
|
27
|
-
|
|
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";
|
|
28
127
|
export class PaginationEngine {
|
|
29
128
|
/**
|
|
30
129
|
* Creates a new pagination engine.
|
|
@@ -34,16 +133,28 @@ export class PaginationEngine {
|
|
|
34
133
|
* @param options - Pagination options
|
|
35
134
|
*/
|
|
36
135
|
constructor(staging, container, options = {}) {
|
|
136
|
+
this.createdPageCount = 0;
|
|
137
|
+
this.footnoteSeparator = null;
|
|
138
|
+
this.footnoteContinuationSeparator = null;
|
|
139
|
+
this.footnoteLayoutLikeWord8 = false;
|
|
37
140
|
this.pendingFootnoteContinuation = null;
|
|
38
141
|
/** Per-section `w:pgNumType` (start / format), read off the section wrappers. */
|
|
39
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;
|
|
40
151
|
this.stagingElement =
|
|
41
152
|
typeof staging === "string"
|
|
42
|
-
?
|
|
153
|
+
? ownerDocument.getElementById(staging)
|
|
43
154
|
: staging;
|
|
44
155
|
this.containerElement =
|
|
45
156
|
typeof container === "string"
|
|
46
|
-
?
|
|
157
|
+
? ownerDocument.getElementById(container)
|
|
47
158
|
: container;
|
|
48
159
|
if (!this.stagingElement) {
|
|
49
160
|
throw new Error("Staging element not found");
|
|
@@ -51,13 +162,27 @@ export class PaginationEngine {
|
|
|
51
162
|
if (!this.containerElement) {
|
|
52
163
|
throw new Error("Container element not found");
|
|
53
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;
|
|
54
174
|
this.scale = options.scale ?? 1;
|
|
55
175
|
this.cssPrefix = options.cssPrefix ?? "page-";
|
|
56
176
|
this.showPageNumbers = options.showPageNumbers ?? true;
|
|
57
177
|
this.pageGap = options.pageGap ?? 20;
|
|
58
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;
|
|
59
183
|
this.hfRegistry = new Map();
|
|
60
184
|
this.footnoteRegistry = new Map();
|
|
185
|
+
this.commentMarginRegistry = new Map();
|
|
61
186
|
}
|
|
62
187
|
/**
|
|
63
188
|
* Runs the pagination process.
|
|
@@ -65,63 +190,524 @@ export class PaginationEngine {
|
|
|
65
190
|
* @returns PaginationResult with page information
|
|
66
191
|
*/
|
|
67
192
|
paginate() {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|
|
87
251
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
94
266
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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);
|
|
110
283
|
}
|
|
111
284
|
else {
|
|
112
|
-
|
|
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
|
+
}
|
|
113
674
|
}
|
|
114
675
|
}
|
|
115
|
-
// Flow blocks into pages
|
|
116
|
-
const sectionPages = this.flowToPages(blocks, run.dims, pageNumber, run.sectionIndex);
|
|
117
|
-
pages.push(...sectionPages);
|
|
118
|
-
pageNumber += sectionPages.length;
|
|
119
676
|
}
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
}
|
|
125
711
|
}
|
|
126
712
|
/** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */
|
|
127
713
|
parsePageNumbering(sections) {
|
|
@@ -164,11 +750,8 @@ export class PaginationEngine {
|
|
|
164
750
|
continue;
|
|
165
751
|
const sectionIndex = parseInt(box.dataset.sectionIndex || "0", 10);
|
|
166
752
|
const pageNumber = parseInt(box.dataset.pageNumber || "1", 10);
|
|
167
|
-
const pageInSection = parseInt(box.dataset.pageInSection || "1", 10);
|
|
168
753
|
const numbering = this.pageNumbering.get(sectionIndex) ?? {};
|
|
169
|
-
|
|
170
|
-
// the document-wide running number.
|
|
171
|
-
const displayed = numbering.start !== undefined ? numbering.start + pageInSection - 1 : pageNumber;
|
|
754
|
+
const displayed = parseInt(box.dataset.displayedPageNumber || String(pageNumber), 10);
|
|
172
755
|
for (const marker of Array.from(markers)) {
|
|
173
756
|
const kind = marker.dataset.field;
|
|
174
757
|
if (kind !== "PAGE" && kind !== "NUMPAGES")
|
|
@@ -181,22 +764,35 @@ export class PaginationEngine {
|
|
|
181
764
|
/**
|
|
182
765
|
* Measures all content blocks in a section.
|
|
183
766
|
*/
|
|
184
|
-
measureBlocks(section, dims) {
|
|
767
|
+
measureBlocks(section, dims, sectionIndex) {
|
|
185
768
|
const blocks = [];
|
|
186
769
|
// Get direct children (paragraphs, tables, divs, etc.)
|
|
187
770
|
const children = Array.from(section.children);
|
|
188
771
|
for (const child of children) {
|
|
772
|
+
this.checkpoint();
|
|
189
773
|
// Skip section dividers that are just wrappers
|
|
190
774
|
if (child.dataset.sectionIndex !== undefined) {
|
|
191
775
|
// Recursively get blocks from nested sections
|
|
192
|
-
const
|
|
776
|
+
const nestedSectionIndex = parseInt(child.dataset.sectionIndex || String(sectionIndex), 10);
|
|
777
|
+
const nestedBlocks = this.measureBlocks(child, dims, nestedSectionIndex);
|
|
193
778
|
blocks.push(...nestedBlocks);
|
|
194
779
|
continue;
|
|
195
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
|
+
}
|
|
196
792
|
// Measure height and margins separately for proper margin collapsing calculation
|
|
197
793
|
// getBoundingClientRect() returns content+padding+border, not margins
|
|
198
794
|
const rect = child.getBoundingClientRect();
|
|
199
|
-
const style =
|
|
795
|
+
const style = this.view.getComputedStyle(child);
|
|
200
796
|
const marginTopPx = parseFloat(style.marginTop) || 0;
|
|
201
797
|
const marginBottomPx = parseFloat(style.marginBottom) || 0;
|
|
202
798
|
const heightPt = pxToPt(rect.height);
|
|
@@ -206,6 +802,7 @@ export class PaginationEngine {
|
|
|
206
802
|
child.classList.contains(`${this.cssPrefix}break`);
|
|
207
803
|
blocks.push({
|
|
208
804
|
element: child,
|
|
805
|
+
sectionIndex,
|
|
209
806
|
heightPt,
|
|
210
807
|
marginTopPt,
|
|
211
808
|
marginBottomPt,
|
|
@@ -218,6 +815,101 @@ export class PaginationEngine {
|
|
|
218
815
|
}
|
|
219
816
|
return blocks;
|
|
220
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
|
+
}
|
|
221
913
|
/**
|
|
222
914
|
* Flows a multi-column (`w:cols`) section's children into CSS-multicol container
|
|
223
915
|
* blocks. Word lays such a section out as N columns inside the same body extent;
|
|
@@ -236,7 +928,7 @@ export class PaginationEngine {
|
|
|
236
928
|
const isBreak = (child) => child.dataset.pageBreak === "true" ||
|
|
237
929
|
child.classList.contains(`${this.cssPrefix}break`);
|
|
238
930
|
const makeContainer = (slice) => {
|
|
239
|
-
const container = document.createElement("div");
|
|
931
|
+
const container = this.document.createElement("div");
|
|
240
932
|
container.style.columnCount = String(columnCount);
|
|
241
933
|
container.style.columnGap = `${columnGapPt}pt`;
|
|
242
934
|
for (const child of slice) {
|
|
@@ -246,8 +938,9 @@ export class PaginationEngine {
|
|
|
246
938
|
};
|
|
247
939
|
let start = 0;
|
|
248
940
|
while (start < children.length) {
|
|
941
|
+
this.checkpoint();
|
|
249
942
|
if (isBreak(children[start])) {
|
|
250
|
-
blocks.push(this.measureElement(children[start], dims));
|
|
943
|
+
blocks.push(this.measureElement(children[start], dims, sectionIndex));
|
|
251
944
|
start++;
|
|
252
945
|
continue;
|
|
253
946
|
}
|
|
@@ -255,10 +948,11 @@ export class PaginationEngine {
|
|
|
255
948
|
// emitted alone, preserving the established oversized-block fallback.
|
|
256
949
|
let end = start + 1;
|
|
257
950
|
let container = makeContainer(children.slice(start, end));
|
|
258
|
-
let measured = this.measureElement(container, dims);
|
|
951
|
+
let measured = this.measureElement(container, dims, sectionIndex);
|
|
259
952
|
while (end < children.length && !isBreak(children[end])) {
|
|
953
|
+
this.checkpoint();
|
|
260
954
|
const candidate = makeContainer(children.slice(start, end + 1));
|
|
261
|
-
const candidateMeasured = this.measureElement(candidate, dims);
|
|
955
|
+
const candidateMeasured = this.measureElement(candidate, dims, sectionIndex);
|
|
262
956
|
if (candidateMeasured.heightPt > maxFragmentHeight)
|
|
263
957
|
break;
|
|
264
958
|
container = candidate;
|
|
@@ -267,6 +961,7 @@ export class PaginationEngine {
|
|
|
267
961
|
}
|
|
268
962
|
blocks.push({
|
|
269
963
|
element: container,
|
|
964
|
+
sectionIndex,
|
|
270
965
|
heightPt: measured.heightPt,
|
|
271
966
|
marginTopPt: measured.marginTopPt,
|
|
272
967
|
marginBottomPt: measured.marginBottomPt,
|
|
@@ -285,8 +980,8 @@ export class PaginationEngine {
|
|
|
285
980
|
* This is intentionally DOM-based: table row heights cannot be inferred from individual
|
|
286
981
|
* rows because wrapping and collapsed borders change the height of a fragment.
|
|
287
982
|
*/
|
|
288
|
-
measureElement(element, dims) {
|
|
289
|
-
const measurementHost = document.createElement("div");
|
|
983
|
+
measureElement(element, dims, sectionIndex) {
|
|
984
|
+
const measurementHost = this.document.createElement("div");
|
|
290
985
|
measurementHost.style.position = "absolute";
|
|
291
986
|
measurementHost.style.visibility = "hidden";
|
|
292
987
|
measurementHost.style.left = "-9999px";
|
|
@@ -295,9 +990,10 @@ export class PaginationEngine {
|
|
|
295
990
|
measurementHost.appendChild(measuredElement);
|
|
296
991
|
this.stagingElement.appendChild(measurementHost);
|
|
297
992
|
const rect = measuredElement.getBoundingClientRect();
|
|
298
|
-
const style =
|
|
993
|
+
const style = this.view.getComputedStyle(measuredElement);
|
|
299
994
|
const measured = {
|
|
300
995
|
element,
|
|
996
|
+
sectionIndex,
|
|
301
997
|
heightPt: pxToPt(rect.height),
|
|
302
998
|
marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
|
|
303
999
|
marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
|
|
@@ -391,6 +1087,7 @@ export class PaginationEngine {
|
|
|
391
1087
|
const knownIds = new Set(existingFootnoteIds);
|
|
392
1088
|
const newIds = [];
|
|
393
1089
|
for (const block of blocks) {
|
|
1090
|
+
this.checkpoint();
|
|
394
1091
|
for (const id of this.extractFootnoteRefs(block.element)) {
|
|
395
1092
|
if (!knownIds.has(id)) {
|
|
396
1093
|
knownIds.add(id);
|
|
@@ -406,7 +1103,7 @@ export class PaginationEngine {
|
|
|
406
1103
|
* send it through the oversized-block fallback again.
|
|
407
1104
|
*/
|
|
408
1105
|
smallestEffectiveContentHeight(dims, sectionIndex) {
|
|
409
|
-
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);
|
|
410
1107
|
}
|
|
411
1108
|
/**
|
|
412
1109
|
* Builds a clone of a simple table wrapper containing a contiguous run of rows.
|
|
@@ -450,10 +1147,11 @@ export class PaginationEngine {
|
|
|
450
1147
|
block.isPageBreak) {
|
|
451
1148
|
return null;
|
|
452
1149
|
}
|
|
453
|
-
const
|
|
454
|
-
if (!
|
|
1150
|
+
const tableElement = wrapper.firstElementChild;
|
|
1151
|
+
if (!tableElement || tableElement.localName !== "table") {
|
|
455
1152
|
return null;
|
|
456
1153
|
}
|
|
1154
|
+
const table = tableElement;
|
|
457
1155
|
const body = table.tBodies.length === 1 ? table.tBodies[0] : null;
|
|
458
1156
|
if (!body ||
|
|
459
1157
|
table.tHead ||
|
|
@@ -476,10 +1174,12 @@ export class PaginationEngine {
|
|
|
476
1174
|
const groups = [];
|
|
477
1175
|
let start = 0;
|
|
478
1176
|
while (start < rows.length) {
|
|
1177
|
+
this.checkpoint();
|
|
479
1178
|
let end = start;
|
|
480
1179
|
while (end < rows.length) {
|
|
1180
|
+
this.checkpoint();
|
|
481
1181
|
const candidate = this.createSimpleTableFragment(wrapper, table, body, rows.slice(start, end + 1), start === 0);
|
|
482
|
-
const measured = this.measureElement(candidate, dims);
|
|
1182
|
+
const measured = this.measureElement(candidate, dims, block.sectionIndex);
|
|
483
1183
|
if (measured.heightPt > maximumFragmentHeight) {
|
|
484
1184
|
break;
|
|
485
1185
|
}
|
|
@@ -510,7 +1210,7 @@ export class PaginationEngine {
|
|
|
510
1210
|
if (!isLast) {
|
|
511
1211
|
fragment.style.setProperty("margin-bottom", "0", "important");
|
|
512
1212
|
}
|
|
513
|
-
const measured = this.measureElement(fragment, dims);
|
|
1213
|
+
const measured = this.measureElement(fragment, dims, block.sectionIndex);
|
|
514
1214
|
if (measured.heightPt + measured.marginTopPt + measured.marginBottomPt >
|
|
515
1215
|
minimumContentHeight) {
|
|
516
1216
|
return null;
|
|
@@ -526,40 +1226,223 @@ export class PaginationEngine {
|
|
|
526
1226
|
return fragments;
|
|
527
1227
|
}
|
|
528
1228
|
/**
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
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.
|
|
532
1235
|
*/
|
|
533
|
-
paragraphFragmentEndpoints(paragraph) {
|
|
534
|
-
const
|
|
535
|
-
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;
|
|
536
1243
|
let textNode;
|
|
537
1244
|
while ((textNode = walker.nextNode())) {
|
|
538
|
-
|
|
539
|
-
|
|
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]))
|
|
540
1267
|
continue;
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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;
|
|
545
1305
|
let match;
|
|
546
|
-
|
|
547
|
-
|
|
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 });
|
|
548
1312
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
+
}
|
|
1330
|
+
}
|
|
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);
|
|
552
1360
|
}
|
|
553
1361
|
}
|
|
554
1362
|
return endpoints;
|
|
555
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
|
+
}
|
|
556
1436
|
/**
|
|
557
1437
|
* Whether a range contains visible text after ignoring bidi/zero-width marks.
|
|
558
1438
|
* Paragraph fragmentation deliberately excludes non-textual descendants, so
|
|
559
1439
|
* this is enough to reject empty head or tail fragments.
|
|
560
1440
|
*/
|
|
561
1441
|
hasVisibleFragmentText(fragment) {
|
|
562
|
-
return (fragment.textContent || "")
|
|
1442
|
+
return this.hasVisibleText(fragment.textContent || "");
|
|
1443
|
+
}
|
|
1444
|
+
hasVisibleText(text) {
|
|
1445
|
+
return text
|
|
563
1446
|
.replace(/[\u200B-\u200F\uFEFF]/g, "")
|
|
564
1447
|
.trim()
|
|
565
1448
|
.length > 0;
|
|
@@ -571,7 +1454,7 @@ export class PaginationEngine {
|
|
|
571
1454
|
* fallback instead of risking broken content or duplicate anchors.
|
|
572
1455
|
*/
|
|
573
1456
|
canFragmentParagraph(block) {
|
|
574
|
-
if (!this.fragmentParagraphs
|
|
1457
|
+
if (!this.fragmentParagraphs) {
|
|
575
1458
|
return false;
|
|
576
1459
|
}
|
|
577
1460
|
const paragraph = block.element;
|
|
@@ -583,8 +1466,26 @@ export class PaginationEngine {
|
|
|
583
1466
|
paragraph.hasAttribute("contenteditable")) {
|
|
584
1467
|
return false;
|
|
585
1468
|
}
|
|
586
|
-
|
|
587
|
-
|
|
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.
|
|
588
1489
|
const unsupportedDescendants = [
|
|
589
1490
|
"br",
|
|
590
1491
|
"img",
|
|
@@ -615,19 +1516,26 @@ export class PaginationEngine {
|
|
|
615
1516
|
"fieldset",
|
|
616
1517
|
"details",
|
|
617
1518
|
"[data-footnote-id]",
|
|
618
|
-
"[data-list-marker]",
|
|
619
|
-
"[data-anchor]",
|
|
620
|
-
"[id]",
|
|
621
1519
|
"[contenteditable]",
|
|
622
1520
|
].join(", ");
|
|
623
1521
|
if (paragraph.querySelector(unsupportedDescendants)) {
|
|
624
1522
|
return false;
|
|
625
1523
|
}
|
|
626
|
-
const
|
|
627
|
-
|
|
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")) ||
|
|
628
1536
|
paragraphStyle.position !== "static" ||
|
|
629
1537
|
paragraphStyle.float !== "none" ||
|
|
630
|
-
paragraphStyle.whiteSpace !== "normal" ||
|
|
1538
|
+
(paragraphStyle.whiteSpace !== "normal" && paragraphStyle.whiteSpace !== "pre-wrap") ||
|
|
631
1539
|
paragraphStyle.breakBefore !== "auto" ||
|
|
632
1540
|
paragraphStyle.breakAfter !== "auto" ||
|
|
633
1541
|
paragraphStyle.breakInside === "avoid" ||
|
|
@@ -636,15 +1544,12 @@ export class PaginationEngine {
|
|
|
636
1544
|
paragraphStyle.pageBreakInside === "avoid") {
|
|
637
1545
|
return false;
|
|
638
1546
|
}
|
|
639
|
-
// A range clone preserves nested inline formatting exactly. Anything that
|
|
640
|
-
// establishes its own box/layout context is intentionally deferred until a
|
|
641
|
-
// future fragmenter can model it accurately.
|
|
642
1547
|
for (const descendant of Array.from(paragraph.querySelectorAll("*"))) {
|
|
643
|
-
const style =
|
|
1548
|
+
const style = this.view.getComputedStyle(descendant);
|
|
644
1549
|
if (style.display !== "inline" ||
|
|
645
1550
|
style.position !== "static" ||
|
|
646
1551
|
style.float !== "none" ||
|
|
647
|
-
style.whiteSpace !== "normal") {
|
|
1552
|
+
(style.whiteSpace !== "normal" && style.whiteSpace !== "pre-wrap")) {
|
|
648
1553
|
return false;
|
|
649
1554
|
}
|
|
650
1555
|
}
|
|
@@ -675,63 +1580,134 @@ export class PaginationEngine {
|
|
|
675
1580
|
return fragment;
|
|
676
1581
|
}
|
|
677
1582
|
/**
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
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`.
|
|
681
1587
|
*/
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
// so binary search avoids measuring every word in long body paragraphs.
|
|
698
|
-
while (low <= high) {
|
|
699
|
-
const middle = Math.floor((low + high) / 2);
|
|
700
|
-
const endpoint = endpoints[middle];
|
|
701
|
-
const headRange = document.createRange();
|
|
702
|
-
headRange.setStart(paragraph, 0);
|
|
703
|
-
headRange.setEnd(endpoint.node, endpoint.offset);
|
|
704
|
-
const headContents = headRange.cloneContents();
|
|
705
|
-
if (!this.hasVisibleFragmentText(headContents)) {
|
|
706
|
-
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))
|
|
707
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
|
+
}
|
|
708
1630
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
+
}
|
|
717
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
|
+
};
|
|
718
1672
|
}
|
|
719
1673
|
if (!best) {
|
|
720
|
-
return
|
|
1674
|
+
return endpoints.length > 0 ? { kind: "no-fit" } : { kind: "indivisible" };
|
|
721
1675
|
}
|
|
722
|
-
const tailRange = document.createRange();
|
|
1676
|
+
const tailRange = this.document.createRange();
|
|
723
1677
|
tailRange.setStart(best.endpoint.node, best.endpoint.offset);
|
|
724
1678
|
tailRange.setEnd(paragraph, paragraph.childNodes.length);
|
|
725
1679
|
const tailContents = tailRange.cloneContents();
|
|
726
|
-
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) {
|
|
727
1697
|
return null;
|
|
728
1698
|
}
|
|
729
|
-
const
|
|
730
|
-
|
|
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);
|
|
731
1707
|
return [
|
|
732
1708
|
{
|
|
733
|
-
...
|
|
734
|
-
element:
|
|
1709
|
+
...headMeasured,
|
|
1710
|
+
element: split.head,
|
|
735
1711
|
keepWithNext: false,
|
|
736
1712
|
keepLines: false,
|
|
737
1713
|
pageBreakBefore: false,
|
|
@@ -739,7 +1715,7 @@ export class PaginationEngine {
|
|
|
739
1715
|
},
|
|
740
1716
|
{
|
|
741
1717
|
...tailMeasured,
|
|
742
|
-
element: tail,
|
|
1718
|
+
element: split.tail,
|
|
743
1719
|
keepWithNext: false,
|
|
744
1720
|
keepLines: false,
|
|
745
1721
|
pageBreakBefore: false,
|
|
@@ -817,7 +1793,11 @@ export class PaginationEngine {
|
|
|
817
1793
|
const registryEl = this.stagingElement.querySelector("#pagination-footnote-registry");
|
|
818
1794
|
if (!registryEl)
|
|
819
1795
|
return registry;
|
|
820
|
-
|
|
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"));
|
|
821
1801
|
for (const entry of entries) {
|
|
822
1802
|
const footnoteId = entry.dataset.footnoteId;
|
|
823
1803
|
if (footnoteId) {
|
|
@@ -827,6 +1807,69 @@ export class PaginationEngine {
|
|
|
827
1807
|
}
|
|
828
1808
|
return registry;
|
|
829
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
|
+
}
|
|
830
1873
|
/**
|
|
831
1874
|
* Extracts footnote reference IDs from an element.
|
|
832
1875
|
*/
|
|
@@ -841,6 +1884,103 @@ export class PaginationEngine {
|
|
|
841
1884
|
}
|
|
842
1885
|
return ids;
|
|
843
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
|
+
}
|
|
844
1984
|
/**
|
|
845
1985
|
* Measures the height of footnotes for given IDs (in points).
|
|
846
1986
|
* Creates a temporary container to measure the footnotes.
|
|
@@ -848,80 +1988,217 @@ export class PaginationEngine {
|
|
|
848
1988
|
* @param contentWidth - Width for measurement
|
|
849
1989
|
* @param continuation - Optional continuation content to include first
|
|
850
1990
|
*/
|
|
851
|
-
measureFootnotesHeight(footnoteIds, contentWidth, continuation) {
|
|
1991
|
+
measureFootnotesHeight(footnoteIds, contentWidth, continuation, partialFootnotes) {
|
|
852
1992
|
const hasContinuation = continuation && continuation.remainingElements.length > 0;
|
|
853
|
-
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) {
|
|
854
2000
|
return 0;
|
|
855
2001
|
}
|
|
856
2002
|
// Measure in the SAME styling context the notes render in: `.page-footnotes` carries
|
|
857
2003
|
// its own font-size and line-height, so measuring without the class sizes the note
|
|
858
2004
|
// block against body type and the reserve can never match what is drawn.
|
|
859
2005
|
// Create a temporary measurement container
|
|
860
|
-
const measureContainer = document.createElement("div");
|
|
2006
|
+
const measureContainer = this.document.createElement("div");
|
|
861
2007
|
measureContainer.style.position = "absolute";
|
|
862
2008
|
measureContainer.style.visibility = "hidden";
|
|
863
2009
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
864
2010
|
measureContainer.style.left = "-9999px";
|
|
865
2011
|
measureContainer.className = this.cssPrefix + "footnotes";
|
|
866
|
-
// Add separator
|
|
867
|
-
|
|
868
|
-
measureContainer.appendChild(hr);
|
|
2012
|
+
// Add the same normal/continuation separator story that will be painted.
|
|
2013
|
+
this.appendFootnoteSeparator(measureContainer, Boolean(hasContinuation));
|
|
869
2014
|
// Add continuation content first (if any)
|
|
870
2015
|
if (hasContinuation) {
|
|
871
|
-
|
|
872
|
-
contWrapper.className = "footnote-continuation";
|
|
873
|
-
for (const el of continuation.remainingElements) {
|
|
874
|
-
contWrapper.appendChild(el.cloneNode(true));
|
|
875
|
-
}
|
|
876
|
-
measureContainer.appendChild(contWrapper);
|
|
2016
|
+
measureContainer.appendChild(this.createFootnoteContinuationWrapper(continuation));
|
|
877
2017
|
}
|
|
878
2018
|
// Add footnotes
|
|
2019
|
+
const partialById = new Map(partialFootnotes?.map((partial) => [partial.footnoteId, partial]) ?? []);
|
|
879
2020
|
for (const id of footnoteIds) {
|
|
2021
|
+
this.checkpoint();
|
|
880
2022
|
const footnote = this.footnoteRegistry.get(id);
|
|
881
2023
|
if (footnote) {
|
|
882
|
-
|
|
2024
|
+
const partial = partialById.get(id);
|
|
2025
|
+
measureContainer.appendChild(partial
|
|
2026
|
+
? this.createPartialFootnoteItem(footnote, partial.fittingElements)
|
|
2027
|
+
: footnote.cloneNode(true));
|
|
883
2028
|
}
|
|
884
2029
|
}
|
|
885
2030
|
// Append to staging for measurement
|
|
886
|
-
this.
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
2031
|
+
this.noteMeasurementHost().appendChild(measureContainer);
|
|
2032
|
+
try {
|
|
2033
|
+
return pxToPt(measureContainer.getBoundingClientRect().height);
|
|
2034
|
+
}
|
|
2035
|
+
finally {
|
|
2036
|
+
measureContainer.remove();
|
|
2037
|
+
}
|
|
893
2038
|
}
|
|
894
2039
|
/**
|
|
895
|
-
*
|
|
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.
|
|
896
2044
|
*/
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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");
|
|
902
2053
|
measureContainer.style.position = "absolute";
|
|
903
2054
|
measureContainer.style.visibility = "hidden";
|
|
904
2055
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
905
2056
|
measureContainer.style.left = "-9999px";
|
|
906
2057
|
measureContainer.className = this.cssPrefix + "footnotes";
|
|
907
|
-
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
+
}
|
|
913
2134
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
+
};
|
|
919
2150
|
}
|
|
920
2151
|
/**
|
|
921
2152
|
* Splits a footnote element into parts that fit within the available height.
|
|
922
2153
|
* Returns the elements that fit and the elements that need to continue.
|
|
923
2154
|
*/
|
|
924
|
-
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);
|
|
925
2202
|
// Get child elements (paragraphs) of the footnote content.
|
|
926
2203
|
//
|
|
927
2204
|
// `fits` is spliced into a freshly built `.footnote-item` > `.footnote-content` wrapper by
|
|
@@ -930,86 +2207,78 @@ export class PaginationEngine {
|
|
|
930
2207
|
// inside another item's content span; the inner block-level div then broke the line, so the
|
|
931
2208
|
// note's number rendered alone above its text — the same visible symptom as the escaped-CSS
|
|
932
2209
|
// bug, from an unrelated cause, on the notes that happened to take a can't-split path.
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
const children = Array.from(footnoteContent.children);
|
|
942
|
-
if (children.length <= 1) {
|
|
943
|
-
// Single paragraph: can't split at paragraph level, but the whole content still fits.
|
|
944
|
-
return {
|
|
945
|
-
fits: children.map((el) => el.cloneNode(true)),
|
|
946
|
-
overflow: [],
|
|
947
|
-
};
|
|
948
|
-
}
|
|
949
|
-
const fits = [];
|
|
950
|
-
const overflow = [];
|
|
951
|
-
let currentHeight = 0;
|
|
952
|
-
// Measure separator line height
|
|
953
|
-
const hrMeasure = document.createElement("div");
|
|
954
|
-
hrMeasure.style.position = "absolute";
|
|
955
|
-
hrMeasure.style.visibility = "hidden";
|
|
956
|
-
hrMeasure.style.width = `${contentWidth}pt`;
|
|
957
|
-
hrMeasure.style.left = "-9999px";
|
|
958
|
-
hrMeasure.className = this.cssPrefix + "footnotes";
|
|
959
|
-
const hr = document.createElement("hr");
|
|
960
|
-
hrMeasure.appendChild(hr);
|
|
961
|
-
this.stagingElement.appendChild(hrMeasure);
|
|
962
|
-
const hrHeight = pxToPt(hrMeasure.getBoundingClientRect().height);
|
|
963
|
-
this.stagingElement.removeChild(hrMeasure);
|
|
964
|
-
currentHeight = hrHeight;
|
|
965
|
-
// Also account for footnote number
|
|
966
|
-
const footnoteNumber = footnoteElement.querySelector(".footnote-number");
|
|
967
|
-
for (let i = 0; i < children.length; i++) {
|
|
968
|
-
const child = children[i];
|
|
969
|
-
// Measure this element
|
|
970
|
-
const measureContainer = document.createElement("div");
|
|
971
|
-
measureContainer.style.position = "absolute";
|
|
972
|
-
measureContainer.style.visibility = "hidden";
|
|
973
|
-
measureContainer.style.width = `${contentWidth}pt`;
|
|
974
|
-
measureContainer.style.left = "-9999px";
|
|
975
|
-
measureContainer.className = this.cssPrefix + "footnotes";
|
|
976
|
-
measureContainer.appendChild(child.cloneNode(true));
|
|
977
|
-
this.stagingElement.appendChild(measureContainer);
|
|
978
|
-
const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
|
|
979
|
-
this.stagingElement.removeChild(measureContainer);
|
|
980
|
-
if (currentHeight + childHeight <= availableHeightPt) {
|
|
981
|
-
fits.push(child.cloneNode(true));
|
|
982
|
-
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
|
+
};
|
|
983
2218
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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));
|
|
988
2273
|
}
|
|
989
2274
|
break;
|
|
990
2275
|
}
|
|
2276
|
+
return { fits, overflow };
|
|
2277
|
+
}
|
|
2278
|
+
finally {
|
|
2279
|
+
measureContainer.remove();
|
|
2280
|
+
layoutContext.remove();
|
|
991
2281
|
}
|
|
992
|
-
return { fits, overflow };
|
|
993
|
-
}
|
|
994
|
-
/**
|
|
995
|
-
* Measures a single footnote's height.
|
|
996
|
-
*/
|
|
997
|
-
measureSingleFootnoteHeight(footnoteId, contentWidth) {
|
|
998
|
-
const footnote = this.footnoteRegistry.get(footnoteId);
|
|
999
|
-
if (!footnote)
|
|
1000
|
-
return 0;
|
|
1001
|
-
const measureContainer = document.createElement("div");
|
|
1002
|
-
measureContainer.style.position = "absolute";
|
|
1003
|
-
measureContainer.style.visibility = "hidden";
|
|
1004
|
-
measureContainer.style.width = `${contentWidth}pt`;
|
|
1005
|
-
measureContainer.style.left = "-9999px";
|
|
1006
|
-
measureContainer.className = this.cssPrefix + "footnotes";
|
|
1007
|
-
measureContainer.appendChild(footnote.cloneNode(true));
|
|
1008
|
-
this.stagingElement.appendChild(measureContainer);
|
|
1009
|
-
const rect = measureContainer.getBoundingClientRect();
|
|
1010
|
-
const heightPt = pxToPt(rect.height);
|
|
1011
|
-
this.stagingElement.removeChild(measureContainer);
|
|
1012
|
-
return heightPt;
|
|
1013
2282
|
}
|
|
1014
2283
|
/**
|
|
1015
2284
|
* Adds footnotes to a page container, including continuation content.
|
|
@@ -1022,11 +2291,9 @@ export class PaginationEngine {
|
|
|
1022
2291
|
if (this.footnoteRegistry.size === 0 && !hasContinuation) {
|
|
1023
2292
|
return;
|
|
1024
2293
|
}
|
|
1025
|
-
// Create a set of partial footnote IDs for quick lookup
|
|
1026
|
-
const partialFootnoteIds = new Set(partialFootnotes?.map(p => p.footnoteId) || []);
|
|
1027
2294
|
// Calculate max height for footnotes area (content height minus margin for body content)
|
|
1028
2295
|
const maxFootnoteHeight = Math.min(footnoteHeight, bands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO);
|
|
1029
|
-
const footnotesDiv = document.createElement("div");
|
|
2296
|
+
const footnotesDiv = this.document.createElement("div");
|
|
1030
2297
|
footnotesDiv.className = `${this.cssPrefix}footnotes`;
|
|
1031
2298
|
footnotesDiv.style.position = "absolute";
|
|
1032
2299
|
// Notes sit at the FOOT OF THE BODY BAND, not at the bottom margin: a footer taller than
|
|
@@ -1038,17 +2305,10 @@ export class PaginationEngine {
|
|
|
1038
2305
|
// Constrain height and clip overflow to prevent footnotes covering body content
|
|
1039
2306
|
footnotesDiv.style.maxHeight = `${maxFootnoteHeight}pt`;
|
|
1040
2307
|
footnotesDiv.style.overflow = "hidden";
|
|
1041
|
-
|
|
1042
|
-
const hr = document.createElement("hr");
|
|
1043
|
-
footnotesDiv.appendChild(hr);
|
|
2308
|
+
this.appendFootnoteSeparator(footnotesDiv, Boolean(hasContinuation));
|
|
1044
2309
|
// Add continuation content first (if any)
|
|
1045
2310
|
if (hasContinuation) {
|
|
1046
|
-
|
|
1047
|
-
contWrapper.className = "footnote-continuation";
|
|
1048
|
-
for (const el of continuation.remainingElements) {
|
|
1049
|
-
contWrapper.appendChild(el.cloneNode(true));
|
|
1050
|
-
}
|
|
1051
|
-
footnotesDiv.appendChild(contWrapper);
|
|
2311
|
+
footnotesDiv.appendChild(this.createFootnoteContinuationWrapper(continuation));
|
|
1052
2312
|
}
|
|
1053
2313
|
// Clone footnotes in order of appearance
|
|
1054
2314
|
for (const id of footnoteIds) {
|
|
@@ -1058,22 +2318,7 @@ export class PaginationEngine {
|
|
|
1058
2318
|
// Render partial footnote (only the fitting elements)
|
|
1059
2319
|
const footnote = this.footnoteRegistry.get(id);
|
|
1060
2320
|
if (footnote) {
|
|
1061
|
-
|
|
1062
|
-
partialDiv.className = "footnote-item";
|
|
1063
|
-
partialDiv.dataset.footnoteId = id;
|
|
1064
|
-
// Add footnote number
|
|
1065
|
-
const numberSpan = footnote.querySelector(".footnote-number");
|
|
1066
|
-
if (numberSpan) {
|
|
1067
|
-
partialDiv.appendChild(numberSpan.cloneNode(true));
|
|
1068
|
-
}
|
|
1069
|
-
// Add only the fitting content
|
|
1070
|
-
const contentSpan = document.createElement("span");
|
|
1071
|
-
contentSpan.className = "footnote-content";
|
|
1072
|
-
for (const el of partial.fittingElements) {
|
|
1073
|
-
contentSpan.appendChild(el.cloneNode(true));
|
|
1074
|
-
}
|
|
1075
|
-
partialDiv.appendChild(contentSpan);
|
|
1076
|
-
footnotesDiv.appendChild(partialDiv);
|
|
2321
|
+
footnotesDiv.appendChild(this.createPartialFootnoteItem(footnote, partial.fittingElements));
|
|
1077
2322
|
}
|
|
1078
2323
|
}
|
|
1079
2324
|
else {
|
|
@@ -1086,10 +2331,8 @@ export class PaginationEngine {
|
|
|
1086
2331
|
}
|
|
1087
2332
|
pageBox.appendChild(footnotesDiv);
|
|
1088
2333
|
}
|
|
1089
|
-
/**
|
|
1090
|
-
|
|
1091
|
-
*/
|
|
1092
|
-
selectHeader(sectionIndex, pageInSection, globalPageNumber) {
|
|
2334
|
+
/** Select the section's first/odd/even header from its one-based page position. */
|
|
2335
|
+
selectHeader(sectionIndex, pageInSection, displayedPageNumber) {
|
|
1093
2336
|
const sectionHf = this.hfRegistry.get(sectionIndex);
|
|
1094
2337
|
if (!sectionHf)
|
|
1095
2338
|
return undefined;
|
|
@@ -1097,17 +2340,19 @@ export class PaginationEngine {
|
|
|
1097
2340
|
if (pageInSection === 1 && sectionHf.headerFirst) {
|
|
1098
2341
|
return sectionHf.headerFirst;
|
|
1099
2342
|
}
|
|
1100
|
-
//
|
|
1101
|
-
|
|
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) {
|
|
1102
2349
|
return sectionHf.headerEven;
|
|
1103
2350
|
}
|
|
1104
2351
|
// Default (odd) pages
|
|
1105
2352
|
return sectionHf.headerDefault;
|
|
1106
2353
|
}
|
|
1107
|
-
/**
|
|
1108
|
-
|
|
1109
|
-
*/
|
|
1110
|
-
selectFooter(sectionIndex, pageInSection, globalPageNumber) {
|
|
2354
|
+
/** Select the section's first/odd/even footer from its one-based page position. */
|
|
2355
|
+
selectFooter(sectionIndex, pageInSection, displayedPageNumber) {
|
|
1111
2356
|
const sectionHf = this.hfRegistry.get(sectionIndex);
|
|
1112
2357
|
if (!sectionHf)
|
|
1113
2358
|
return undefined;
|
|
@@ -1115,8 +2360,7 @@ export class PaginationEngine {
|
|
|
1115
2360
|
if (pageInSection === 1 && sectionHf.footerFirst) {
|
|
1116
2361
|
return sectionHf.footerFirst;
|
|
1117
2362
|
}
|
|
1118
|
-
|
|
1119
|
-
if (globalPageNumber % 2 === 0 && sectionHf.footerEven) {
|
|
2363
|
+
if (displayedPageNumber % 2 === 0 && sectionHf.footerEven) {
|
|
1120
2364
|
return sectionHf.footerEven;
|
|
1121
2365
|
}
|
|
1122
2366
|
// Default (odd) pages
|
|
@@ -1132,18 +2376,18 @@ export class PaginationEngine {
|
|
|
1132
2376
|
* Deterministic: it depends only on the section's page setup and the registry's pre-measured
|
|
1133
2377
|
* story heights, never on the page's content, which is what keeps it lazy-loading compatible.
|
|
1134
2378
|
*/
|
|
1135
|
-
getPageBands(dims, sectionIndex, pageInSection,
|
|
2379
|
+
getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber) {
|
|
1136
2380
|
const sectionHf = this.hfRegistry.get(sectionIndex);
|
|
1137
|
-
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));
|
|
1138
2382
|
}
|
|
1139
2383
|
/**
|
|
1140
2384
|
* The measured height of the running story this page position selects, mirroring
|
|
1141
2385
|
* {@link selectHeader}/{@link selectFooter}. Zero when the page has no such story.
|
|
1142
2386
|
*/
|
|
1143
|
-
selectStoryHeight(first, even, fallback, pageInSection,
|
|
2387
|
+
selectStoryHeight(first, even, fallback, pageInSection, displayedPageNumber) {
|
|
1144
2388
|
if (pageInSection === 1 && first != null)
|
|
1145
2389
|
return first;
|
|
1146
|
-
if (
|
|
2390
|
+
if (displayedPageNumber % 2 === 0 && even != null)
|
|
1147
2391
|
return even;
|
|
1148
2392
|
return fallback ?? 0;
|
|
1149
2393
|
}
|
|
@@ -1157,7 +2401,7 @@ export class PaginationEngine {
|
|
|
1157
2401
|
*/
|
|
1158
2402
|
measureHeaderFooterHeight(source, contentWidth) {
|
|
1159
2403
|
// Create a temporary measurement container
|
|
1160
|
-
const measureContainer = document.createElement("div");
|
|
2404
|
+
const measureContainer = this.document.createElement("div");
|
|
1161
2405
|
measureContainer.style.position = "absolute";
|
|
1162
2406
|
measureContainer.style.visibility = "hidden";
|
|
1163
2407
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
@@ -1180,25 +2424,49 @@ export class PaginationEngine {
|
|
|
1180
2424
|
* Implements a single-pass, forward-only algorithm that is compatible with future lazy loading.
|
|
1181
2425
|
* Supports footnote continuation - long footnotes can split across pages.
|
|
1182
2426
|
*/
|
|
1183
|
-
flowToPages(blocks, dims, startPageNumber, sectionIndex) {
|
|
2427
|
+
flowToPages(blocks, dims, startPageNumber, sectionIndex, sectionDimensions) {
|
|
1184
2428
|
const pages = [];
|
|
1185
2429
|
let currentContent = [];
|
|
1186
2430
|
let pageNumber = startPageNumber;
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
+
};
|
|
1189
2454
|
// Get effective content height for first page (accounts for header/footer sizes)
|
|
1190
|
-
let { bodyHeight: effectiveContentHeight } = this.getPageBands(
|
|
2455
|
+
let { bodyHeight: effectiveContentHeight } = this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber());
|
|
1191
2456
|
let remainingHeight = effectiveContentHeight;
|
|
1192
2457
|
// Track the previous block's bottom margin for margin collapsing
|
|
1193
2458
|
let prevMarginBottomPt = 0;
|
|
1194
2459
|
// Track footnote IDs for the current page
|
|
1195
2460
|
let currentFootnoteIds = [];
|
|
2461
|
+
let currentPageHasFootnoteReference = false;
|
|
1196
2462
|
// Track height consumed by footnotes on current page
|
|
1197
2463
|
let currentFootnoteHeight = 0;
|
|
1198
2464
|
// Track footnote continuation for current page (from previous page)
|
|
1199
2465
|
let currentContinuation = this.pendingFootnoteContinuation;
|
|
1200
2466
|
// Track any new continuation that will carry to next page
|
|
1201
2467
|
let nextPageContinuation = null;
|
|
2468
|
+
let currentContinuationPartitioned = false;
|
|
2469
|
+
let currentPageAdmitted = false;
|
|
1202
2470
|
/**
|
|
1203
2471
|
* Whole notes that could not be started on this page and must render on the next one.
|
|
1204
2472
|
*
|
|
@@ -1213,29 +2481,133 @@ export class PaginationEngine {
|
|
|
1213
2481
|
let deferredFootnoteIds = [];
|
|
1214
2482
|
// Track partial footnotes for current page (footnotes that were split)
|
|
1215
2483
|
let currentPartialFootnotes = [];
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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) => {
|
|
1221
2514
|
const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0;
|
|
1222
|
-
if (currentContent.length === 0 &&
|
|
2515
|
+
if (!forceEmptyPage && currentContent.length === 0 && currentFootnoteIds.length === 0
|
|
2516
|
+
&& !hasCurrentContinuation) {
|
|
2517
|
+
adoptEmptyPageOwner(nextPageSectionIndex);
|
|
1223
2518
|
return;
|
|
1224
|
-
|
|
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);
|
|
1225
2587
|
pages.push(page);
|
|
2588
|
+
precedingDisplayedPageNumber = ownedDisplayedPageNumber;
|
|
1226
2589
|
pageNumber++;
|
|
1227
|
-
pageInSection++;
|
|
1228
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
|
+
}
|
|
1229
2598
|
// Get effective content height for new page position
|
|
1230
|
-
const newBands = this.getPageBands(
|
|
2599
|
+
const newBands = this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber());
|
|
1231
2600
|
effectiveContentHeight = newBands.bodyHeight;
|
|
1232
2601
|
remainingHeight = effectiveContentHeight;
|
|
1233
2602
|
prevMarginBottomPt = 0; // Reset margin tracking for new page
|
|
1234
2603
|
currentFootnoteIds = []; // Reset footnotes for new page
|
|
2604
|
+
currentPageHasFootnoteReference = false;
|
|
1235
2605
|
currentPartialFootnotes = []; // Reset partial footnotes for new page
|
|
1236
2606
|
// Carry over continuation to next page
|
|
1237
2607
|
currentContinuation = nextPageContinuation;
|
|
1238
2608
|
nextPageContinuation = null;
|
|
2609
|
+
currentContinuationPartitioned = false;
|
|
2610
|
+
currentPageAdmitted = false;
|
|
1239
2611
|
// Notes that never got started land at the top of the new page's note area. They are
|
|
1240
2612
|
// ordinary footnotes from here on, so the normal fitting path handles them — and because
|
|
1241
2613
|
// this page is fresh, the space they were denied now exists.
|
|
@@ -1243,27 +2615,86 @@ export class PaginationEngine {
|
|
|
1243
2615
|
currentFootnoteIds = [...deferredFootnoteIds];
|
|
1244
2616
|
deferredFootnoteIds = [];
|
|
1245
2617
|
}
|
|
1246
|
-
//
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
if (currentFootnoteIds.length > 0) {
|
|
1254
|
-
currentFootnoteHeight += this.measureFootnotesHeight(currentFootnoteIds, dims.contentWidth, null);
|
|
1255
|
-
}
|
|
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);
|
|
1256
2625
|
};
|
|
2626
|
+
const pageHasPayload = () => currentContent.length > 0
|
|
2627
|
+
|| currentFootnoteIds.length > 0
|
|
2628
|
+
|| (currentContinuation?.remainingElements.length ?? 0) > 0;
|
|
1257
2629
|
for (let i = 0; i < blocks.length; i++) {
|
|
2630
|
+
this.checkpoint();
|
|
1258
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;
|
|
1259
2690
|
// Handle explicit page breaks
|
|
1260
2691
|
if (block.isPageBreak) {
|
|
1261
|
-
finishPage();
|
|
2692
|
+
finishPage(block.sectionIndex);
|
|
1262
2693
|
continue;
|
|
1263
2694
|
}
|
|
1264
2695
|
// Handle page break before
|
|
1265
2696
|
if (block.pageBreakBefore && currentContent.length > 0) {
|
|
1266
|
-
finishPage();
|
|
2697
|
+
finishPage(block.sectionIndex);
|
|
1267
2698
|
}
|
|
1268
2699
|
// A series of keep-with-next blocks is one indivisible placement unit
|
|
1269
2700
|
// when it can fit a new page. The former one-block lookahead was never
|
|
@@ -1285,43 +2716,43 @@ export class PaginationEngine {
|
|
|
1285
2716
|
const newChainFootnoteIds = this.collectNewFootnoteIds(keepChain, currentFootnoteIds);
|
|
1286
2717
|
let additionalChainFootnoteHeight = 0;
|
|
1287
2718
|
if (newChainFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
|
|
1288
|
-
const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds],
|
|
2719
|
+
const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds], blockDimensions.contentWidth, currentContinuation);
|
|
1289
2720
|
additionalChainFootnoteHeight = Math.max(0, totalChainFootnoteHeight - currentFootnoteHeight);
|
|
1290
2721
|
}
|
|
1291
|
-
const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0, pageInSection) +
|
|
2722
|
+
const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0, pageInSection(block.sectionIndex)) +
|
|
1292
2723
|
additionalChainFootnoteHeight;
|
|
1293
2724
|
const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
|
|
1294
2725
|
if (currentChainHeight > currentAvailableHeight) {
|
|
1295
|
-
const nextPageBands = this.getPageBands(
|
|
1296
|
-
const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true, pageInSection + 1);
|
|
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));
|
|
1297
2728
|
// finishPage transfers this continuation to the new page's
|
|
1298
2729
|
// currentContinuation state, so include it in the destination
|
|
1299
2730
|
// page's footnote reservation before deciding to move the chain.
|
|
1300
|
-
const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds,
|
|
2731
|
+
const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds, blockDimensions.contentWidth, nextPageContinuation);
|
|
1301
2732
|
if (freshChainBodyHeight + freshChainFootnoteHeight <=
|
|
1302
2733
|
nextPageBands.bodyHeight) {
|
|
1303
|
-
finishPage();
|
|
2734
|
+
finishPage(block.sectionIndex);
|
|
1304
2735
|
}
|
|
1305
2736
|
}
|
|
1306
2737
|
}
|
|
1307
2738
|
}
|
|
1308
2739
|
// Extract footnote references from this block
|
|
1309
|
-
const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
|
|
1310
2740
|
// Only count new footnotes (not already on this page)
|
|
1311
2741
|
const newFootnoteIds = this.collectNewFootnoteIds([block], currentFootnoteIds);
|
|
1312
2742
|
// Calculate additional footnote height if this block is added
|
|
2743
|
+
let combinedFootnoteHeight = currentFootnoteHeight;
|
|
1313
2744
|
let additionalFootnoteHeight = 0;
|
|
1314
2745
|
if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
|
|
1315
2746
|
// Measure the combined height of all footnotes that would be on this page
|
|
1316
2747
|
// (including any continuation)
|
|
1317
2748
|
const combinedFootnoteIds = [...currentFootnoteIds, ...newFootnoteIds];
|
|
1318
|
-
|
|
1319
|
-
additionalFootnoteHeight =
|
|
2749
|
+
combinedFootnoteHeight = this.measureFootnotesHeight(combinedFootnoteIds, blockDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
|
|
2750
|
+
additionalFootnoteHeight = Math.max(0, combinedFootnoteHeight - currentFootnoteHeight);
|
|
1320
2751
|
}
|
|
1321
2752
|
// Calculate the effective height this block will consume
|
|
1322
2753
|
// Account for margin collapsing: the gap between blocks is max(prevBottom, currTop), not sum
|
|
1323
2754
|
const isFirstOnPage = currentContent.length === 0;
|
|
1324
|
-
const effectiveMarginTop = this.effectiveBlockMarginTop(block, prevMarginBottomPt, isFirstOnPage, pageInSection);
|
|
2755
|
+
const effectiveMarginTop = this.effectiveBlockMarginTop(block, prevMarginBottomPt, isFirstOnPage, pageInSection(block.sectionIndex));
|
|
1325
2756
|
// Visible height = top margin gap + content + footnote space
|
|
1326
2757
|
// Note: bottom margin is NOT included in the fit check because the last block's
|
|
1327
2758
|
// bottom margin extends beyond the content area and is clipped by overflow:hidden.
|
|
@@ -1332,13 +2763,12 @@ export class PaginationEngine {
|
|
|
1332
2763
|
// Calculate maximum footnote area for this page (can expand into body content space)
|
|
1333
2764
|
const bodyContentUsed = effectiveContentHeight - remainingHeight;
|
|
1334
2765
|
const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
|
|
1335
|
-
const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
|
|
1336
2766
|
// A paragraph that cannot fit as a whole may still have a simple text-only
|
|
1337
2767
|
// prefix that fits this page. Fragment before the ordinary next-page or
|
|
1338
2768
|
// oversized fallback so the cloned head participates in the same margin
|
|
1339
2769
|
// and footnote accounting as every other block.
|
|
1340
2770
|
if (blockSpace > effectiveRemainingHeight) {
|
|
1341
|
-
const paragraphFragments = this.tryFragmentParagraph(block,
|
|
2771
|
+
const paragraphFragments = this.tryFragmentParagraph(block, blockDimensions, effectiveRemainingHeight, effectiveMarginTop);
|
|
1342
2772
|
if (paragraphFragments) {
|
|
1343
2773
|
blocks.splice(i, 1, ...paragraphFragments);
|
|
1344
2774
|
i--;
|
|
@@ -1346,49 +2776,69 @@ export class PaginationEngine {
|
|
|
1346
2776
|
}
|
|
1347
2777
|
}
|
|
1348
2778
|
// Check if block fits on current page (including its footnotes)
|
|
1349
|
-
if (blockSpace <= effectiveRemainingHeight
|
|
2779
|
+
if (blockSpace <= effectiveRemainingHeight
|
|
2780
|
+
&& (combinedFootnoteHeight === 0
|
|
2781
|
+
|| combinedFootnoteHeight + FOOTNOTE_MEASUREMENT_GUARD_PT <= maxFootnoteArea)) {
|
|
1350
2782
|
// Block fits with current footnote allocation
|
|
1351
|
-
|
|
2783
|
+
markSectionPlaced(block.sectionIndex);
|
|
2784
|
+
currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection(block.sectionIndex)));
|
|
1352
2785
|
remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt);
|
|
1353
2786
|
prevMarginBottomPt = block.marginBottomPt;
|
|
1354
2787
|
// Add new footnotes to current page
|
|
1355
2788
|
if (newFootnoteIds.length > 0) {
|
|
1356
2789
|
currentFootnoteIds.push(...newFootnoteIds);
|
|
1357
|
-
currentFootnoteHeight
|
|
2790
|
+
currentFootnoteHeight = combinedFootnoteHeight;
|
|
1358
2791
|
}
|
|
2792
|
+
currentPageHasFootnoteReference || (currentPageHasFootnoteReference = allBlockFootnoteIds.length > 0);
|
|
1359
2793
|
}
|
|
1360
|
-
else if (block.heightPt + this.effectiveBlockMarginTop(block, 0, true, pageInSection + 1) <=
|
|
1361
|
-
effectiveContentHeight) {
|
|
2794
|
+
else if (block.heightPt + this.effectiveBlockMarginTop(block, 0, true, pageInSection(block.sectionIndex, pageNumber + 1)) <= freshBlockPageBodyHeight) {
|
|
1362
2795
|
// Block doesn't fit with current allocation - try expanding footnote area
|
|
1363
2796
|
const blockSpaceWithoutFootnotes = effectiveMarginTop + block.heightPt;
|
|
1364
|
-
// Check if block fits if we expand footnote area
|
|
1365
|
-
// We can expand footnotes up to maxFootnoteArea, leaving room for body content
|
|
1366
|
-
const minBodySpaceNeeded = bodyContentUsed + blockSpaceWithoutFootnotes + MIN_BODY_CONTENT_HEIGHT;
|
|
1367
|
-
const canExpandFootnotes = minBodySpaceNeeded <= effectiveContentHeight;
|
|
1368
2797
|
if (newFootnoteIds.length > 0 && blockSpaceWithoutFootnotes <= effectiveRemainingHeight) {
|
|
1369
2798
|
// Block itself fits, but footnotes don't - expand footnote area
|
|
1370
|
-
|
|
2799
|
+
markSectionPlaced(block.sectionIndex);
|
|
2800
|
+
currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection(block.sectionIndex)));
|
|
1371
2801
|
remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt);
|
|
1372
2802
|
prevMarginBottomPt = block.marginBottomPt;
|
|
1373
2803
|
// Calculate EXPANDED space available for footnotes
|
|
1374
2804
|
// Footnotes can take up to maxFootnoteArea or all remaining space, whichever is less
|
|
1375
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;
|
|
1376
2813
|
// Try to fit as much of each new footnote as possible in expanded area
|
|
1377
|
-
for (
|
|
2814
|
+
for (let noteIndex = 0; noteIndex < newFootnoteIds.length; noteIndex++) {
|
|
2815
|
+
this.checkpoint();
|
|
2816
|
+
const footnoteId = newFootnoteIds[noteIndex];
|
|
1378
2817
|
const footnote = this.footnoteRegistry.get(footnoteId);
|
|
1379
2818
|
if (!footnote)
|
|
1380
2819
|
continue;
|
|
1381
|
-
|
|
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);
|
|
1382
2828
|
const spaceLeftForFootnotes = availableForFootnotes - currentFootnoteHeight;
|
|
1383
|
-
if (
|
|
2829
|
+
if (candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT <= availableForFootnotes) {
|
|
1384
2830
|
// Whole footnote fits in expanded area
|
|
1385
2831
|
currentFootnoteIds.push(footnoteId);
|
|
1386
|
-
currentFootnoteHeight
|
|
2832
|
+
currentFootnoteHeight = candidateHeight;
|
|
1387
2833
|
}
|
|
1388
2834
|
else {
|
|
1389
2835
|
// Footnote needs to be split - use all available expanded space
|
|
1390
2836
|
if (spaceLeftForFootnotes > 20) { // Minimum space to start a footnote
|
|
1391
|
-
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
|
+
});
|
|
1392
2842
|
if (fits.length > 0) {
|
|
1393
2843
|
// Add partial footnote to current page
|
|
1394
2844
|
currentFootnoteIds.push(footnoteId);
|
|
@@ -1399,88 +2849,48 @@ export class PaginationEngine {
|
|
|
1399
2849
|
if (overflow.length > 0) {
|
|
1400
2850
|
nextPageContinuation = {
|
|
1401
2851
|
footnoteId,
|
|
2852
|
+
sourceAnchorId: footnote.dataset.sourceAnchorId,
|
|
1402
2853
|
remainingElements: overflow
|
|
1403
2854
|
};
|
|
1404
2855
|
}
|
|
1405
|
-
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
|
+
}
|
|
1406
2861
|
}
|
|
1407
2862
|
else {
|
|
1408
2863
|
// Nothing of this note fits: defer the WHOLE note rather than assigning the
|
|
1409
2864
|
// single continuation slot, which a later note on this page would overwrite.
|
|
1410
|
-
deferredFootnoteIds.push(
|
|
2865
|
+
deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
|
|
2866
|
+
break;
|
|
1411
2867
|
}
|
|
1412
2868
|
}
|
|
1413
2869
|
else {
|
|
1414
2870
|
// Not enough space to even start the note — same deferral.
|
|
1415
|
-
deferredFootnoteIds.push(
|
|
2871
|
+
deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
|
|
2872
|
+
break;
|
|
1416
2873
|
}
|
|
1417
2874
|
}
|
|
1418
2875
|
}
|
|
1419
|
-
|
|
1420
|
-
else if (canExpandFootnotes && newFootnoteIds.length > 0) {
|
|
1421
|
-
// Block doesn't fit with current layout, but might fit if we expand footnote area first
|
|
1422
|
-
// This handles the case where we need to give footnotes more space BEFORE adding the block
|
|
1423
|
-
// First, try to fit more of current footnotes by expanding the area
|
|
1424
|
-
// Then check if the block fits in reduced body space
|
|
1425
|
-
const expandedFootnoteSpace = Math.min(maxFootnoteArea, additionalFootnoteHeight + currentFootnoteHeight);
|
|
1426
|
-
const bodySpaceAfterExpansion = effectiveContentHeight - expandedFootnoteSpace;
|
|
1427
|
-
if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) {
|
|
1428
|
-
// Block fits after expanding footnote area.
|
|
1429
|
-
currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection));
|
|
1430
|
-
// `remainingHeight` tracks BODY consumption only — every other branch maintains it
|
|
1431
|
-
// that way, and the footnote reserve is applied separately via `effectiveRemainingHeight`
|
|
1432
|
-
// at the top of each iteration. Assigning `bodySpaceAfterExpansion - …` here folded the
|
|
1433
|
-
// reserve in a second time, so a later block would see a body budget short by the whole
|
|
1434
|
-
// footnote area. Consistency fix: no measurable difference on the documents tested, but
|
|
1435
|
-
// the two meanings must not coexist or the next change here inherits a latent bug.
|
|
1436
|
-
remainingHeight = bodySpaceAfterExpansion - bodyContentUsed - blockSpaceWithoutFootnotes;
|
|
1437
|
-
prevMarginBottomPt = block.marginBottomPt;
|
|
1438
|
-
currentFootnoteIds.push(...newFootnoteIds);
|
|
1439
|
-
currentFootnoteHeight = expandedFootnoteSpace;
|
|
1440
|
-
}
|
|
1441
|
-
else {
|
|
1442
|
-
// Still doesn't fit - start new page
|
|
1443
|
-
finishPage();
|
|
1444
|
-
const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
|
|
1445
|
-
? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
|
|
1446
|
-
: (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
|
|
1447
|
-
const newPageMarginTop = this.effectiveBlockMarginTop(block, 0, true, pageInSection);
|
|
1448
|
-
const newPageSpace = newPageMarginTop + block.heightPt + block.marginBottomPt;
|
|
1449
|
-
currentContent.push(this.cloneBlockForPage(block, true, pageInSection));
|
|
1450
|
-
remainingHeight = effectiveContentHeight - newPageSpace;
|
|
1451
|
-
prevMarginBottomPt = block.marginBottomPt;
|
|
1452
|
-
// Merge, never replace: finishPage() may have just seeded this page with notes deferred
|
|
1453
|
-
// from the previous one, and overwriting here dropped them from the document.
|
|
1454
|
-
currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
|
|
1455
|
-
currentFootnoteHeight = newPageFootnoteHeight;
|
|
1456
|
-
}
|
|
2876
|
+
currentPageHasFootnoteReference || (currentPageHasFootnoteReference = allBlockFootnoteIds.length > 0);
|
|
1457
2877
|
}
|
|
1458
2878
|
else {
|
|
1459
|
-
// Block itself doesn't fit
|
|
1460
|
-
|
|
1461
|
-
//
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
: (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
|
|
1466
|
-
const newPageMarginTop = this.effectiveBlockMarginTop(block, 0, true, pageInSection);
|
|
1467
|
-
const newPageSpace = newPageMarginTop + block.heightPt + block.marginBottomPt;
|
|
1468
|
-
currentContent.push(this.cloneBlockForPage(block, true, pageInSection));
|
|
1469
|
-
remainingHeight = effectiveContentHeight - newPageSpace;
|
|
1470
|
-
prevMarginBottomPt = block.marginBottomPt;
|
|
1471
|
-
// Merge, never replace: finishPage() may have just seeded this page with notes deferred
|
|
1472
|
-
// from the previous one, and overwriting here dropped them from the document.
|
|
1473
|
-
currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
|
|
1474
|
-
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;
|
|
1475
2885
|
}
|
|
1476
2886
|
}
|
|
1477
2887
|
else {
|
|
1478
2888
|
// Block is taller than a page. Ordinary tables can be split at complete
|
|
1479
2889
|
// row boundaries; every other block retains the established overflow path.
|
|
1480
|
-
const tableFragments = this.trySplitSimpleOversizedTable(block,
|
|
2890
|
+
const tableFragments = this.trySplitSimpleOversizedTable(block, dimensionsFor(block.sectionIndex), block.sectionIndex);
|
|
1481
2891
|
if (tableFragments) {
|
|
1482
2892
|
if (currentContent.length > 0 || currentContinuation) {
|
|
1483
|
-
finishPage();
|
|
2893
|
+
finishPage(block.sectionIndex);
|
|
1484
2894
|
}
|
|
1485
2895
|
blocks.splice(i, 1, ...tableFragments);
|
|
1486
2896
|
i--;
|
|
@@ -1489,18 +2899,30 @@ export class PaginationEngine {
|
|
|
1489
2899
|
// Unsupported oversized blocks are intentionally left intact. Splitting
|
|
1490
2900
|
// arbitrary HTML, merged tables, or footnote-bearing tables would be less
|
|
1491
2901
|
// correct than the prior clipped fallback.
|
|
1492
|
-
if (
|
|
1493
|
-
finishPage();
|
|
2902
|
+
if (pageHasPayload()) {
|
|
2903
|
+
finishPage(block.sectionIndex);
|
|
2904
|
+
i--;
|
|
2905
|
+
continue;
|
|
1494
2906
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
//
|
|
1498
|
-
|
|
1499
|
-
|
|
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);
|
|
1500
2915
|
}
|
|
1501
2916
|
}
|
|
1502
2917
|
// Finish last page
|
|
1503
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
|
+
}
|
|
1504
2926
|
// Store any remaining continuation for next section
|
|
1505
2927
|
this.pendingFootnoteContinuation = nextPageContinuation;
|
|
1506
2928
|
return pages;
|
|
@@ -1527,6 +2949,19 @@ export class PaginationEngine {
|
|
|
1527
2949
|
el.setAttribute("contenteditable", "false");
|
|
1528
2950
|
}
|
|
1529
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
|
+
}
|
|
1530
2965
|
/**
|
|
1531
2966
|
* Resolves floating DrawingML objects after their anchor paragraphs have landed on a page.
|
|
1532
2967
|
*
|
|
@@ -1553,7 +2988,7 @@ export class PaginationEngine {
|
|
|
1553
2988
|
staticLeft: toPageX(staticRect.left),
|
|
1554
2989
|
staticTop: toPageY(staticRect.top),
|
|
1555
2990
|
lineHeight: paragraph
|
|
1556
|
-
? (parseFloat(getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint
|
|
2991
|
+
? (parseFloat(this.view.getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint
|
|
1557
2992
|
: staticRect.height / pixelsPerPoint,
|
|
1558
2993
|
paragraphLeft: toPageX(paragraphRect.left),
|
|
1559
2994
|
paragraphTop: toPageY(paragraphRect.top),
|
|
@@ -1665,13 +3100,21 @@ export class PaginationEngine {
|
|
|
1665
3100
|
fraction = pageNumber % 2 === 1 ? 1 : 0;
|
|
1666
3101
|
return reference.start + (reference.size - objectSize) * fraction;
|
|
1667
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
|
+
}
|
|
1668
3109
|
/**
|
|
1669
3110
|
* Creates a page container element.
|
|
1670
3111
|
*/
|
|
1671
|
-
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();
|
|
1672
3115
|
// Create page box at full size, then scale the entire box
|
|
1673
3116
|
// This ensures proper clipping and consistent scaling of all elements
|
|
1674
|
-
const pageBox = document.createElement("div");
|
|
3117
|
+
const pageBox = this.document.createElement("div");
|
|
1675
3118
|
pageBox.className = `${this.cssPrefix}box`;
|
|
1676
3119
|
pageBox.style.width = `${dims.pageWidth}pt`;
|
|
1677
3120
|
pageBox.style.height = `${dims.pageHeight}pt`;
|
|
@@ -1681,35 +3124,40 @@ export class PaginationEngine {
|
|
|
1681
3124
|
// Zoom affects layout (no negative margin hack needed) and renders text more crisply
|
|
1682
3125
|
// Note: zoom is non-standard but supported in all major browsers
|
|
1683
3126
|
if (this.scale !== 1) {
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
+
}
|
|
1698
3141
|
}
|
|
1699
3142
|
// Hint browser for GPU compositing and layout isolation
|
|
1700
3143
|
pageBox.style.willChange = "transform";
|
|
1701
3144
|
pageBox.style.contain = "layout paint";
|
|
1702
3145
|
pageBox.dataset.pageNumber = String(pageNumber);
|
|
1703
3146
|
pageBox.dataset.sectionIndex = String(sectionIndex);
|
|
3147
|
+
pageBox.dataset.displayedPageNumber = String(displayedPageNumber);
|
|
3148
|
+
if (isSectionFiller)
|
|
3149
|
+
pageBox.dataset.sectionFiller = "true";
|
|
1704
3150
|
// Needed by substitutePageNumberFields: a section that restarts numbering counts from its own
|
|
1705
3151
|
// first page, not from the document's.
|
|
1706
3152
|
pageBox.dataset.pageInSection = String(pageInSection);
|
|
1707
3153
|
// Where the three bands sit on this page (no re-measurement needed)
|
|
1708
|
-
const bands = this.getPageBands(dims, sectionIndex, pageInSection,
|
|
3154
|
+
const bands = this.getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber);
|
|
1709
3155
|
// Add header if available for this section/page
|
|
1710
|
-
const headerSource =
|
|
3156
|
+
const headerSource = isSectionFiller
|
|
3157
|
+
? undefined
|
|
3158
|
+
: this.selectHeader(sectionIndex, pageInSection, displayedPageNumber);
|
|
1711
3159
|
if (headerSource) {
|
|
1712
|
-
const headerDiv = document.createElement("div");
|
|
3160
|
+
const headerDiv = this.document.createElement("div");
|
|
1713
3161
|
headerDiv.className = `${this.cssPrefix}header`;
|
|
1714
3162
|
headerDiv.style.position = "absolute";
|
|
1715
3163
|
// `w:header` is the distance to the TOP of the story, and the story grows downward from
|
|
@@ -1737,7 +3185,7 @@ export class PaginationEngine {
|
|
|
1737
3185
|
// Create content area using pre-computed effective heights
|
|
1738
3186
|
const contentAreaTop = bands.bodyTop;
|
|
1739
3187
|
const contentAreaHeight = bands.bodyHeight;
|
|
1740
|
-
const contentArea = document.createElement("div");
|
|
3188
|
+
const contentArea = this.document.createElement("div");
|
|
1741
3189
|
contentArea.className = `${this.cssPrefix}content`;
|
|
1742
3190
|
contentArea.style.position = "absolute";
|
|
1743
3191
|
contentArea.style.top = `${contentAreaTop}pt`;
|
|
@@ -1752,13 +3200,53 @@ export class PaginationEngine {
|
|
|
1752
3200
|
pageBox.appendChild(contentArea);
|
|
1753
3201
|
// Add footnotes if any references appear on this page (or continuation from previous)
|
|
1754
3202
|
const hasContinuation = continuation && continuation.remainingElements.length > 0;
|
|
1755
|
-
if (footnoteIds.length > 0 || hasContinuation) {
|
|
3203
|
+
if (!isSectionFiller && (footnoteIds.length > 0 || hasContinuation)) {
|
|
1756
3204
|
this.addPageFootnotes(pageBox, footnoteIds, dims, bands, footnoteHeight, continuation, partialFootnotes);
|
|
1757
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
|
+
}
|
|
1758
3244
|
// Add footer if available for this section/page
|
|
1759
|
-
const footerSource =
|
|
3245
|
+
const footerSource = isSectionFiller
|
|
3246
|
+
? undefined
|
|
3247
|
+
: this.selectFooter(sectionIndex, pageInSection, displayedPageNumber);
|
|
1760
3248
|
if (footerSource) {
|
|
1761
|
-
const footerDiv = document.createElement("div");
|
|
3249
|
+
const footerDiv = this.document.createElement("div");
|
|
1762
3250
|
footerDiv.className = `${this.cssPrefix}footer`;
|
|
1763
3251
|
footerDiv.style.position = "absolute";
|
|
1764
3252
|
// `w:footer` is the distance to the BOTTOM of the story, and the story grows upward from
|
|
@@ -1783,8 +3271,8 @@ export class PaginationEngine {
|
|
|
1783
3271
|
pageBox.appendChild(footerDiv);
|
|
1784
3272
|
}
|
|
1785
3273
|
// Add page number (will be hidden by CSS if document has footer)
|
|
1786
|
-
if (this.showPageNumbers) {
|
|
1787
|
-
const pageNum = document.createElement("div");
|
|
3274
|
+
if (this.showPageNumbers && !isSectionFiller) {
|
|
3275
|
+
const pageNum = this.document.createElement("div");
|
|
1788
3276
|
pageNum.className = `${this.cssPrefix}number`;
|
|
1789
3277
|
pageNum.textContent = String(pageNumber);
|
|
1790
3278
|
pageBox.appendChild(pageNum);
|
|
@@ -1847,8 +3335,9 @@ export class PaginationEngine {
|
|
|
1847
3335
|
* ```
|
|
1848
3336
|
*/
|
|
1849
3337
|
export function paginateHtml(html, container, options = {}) {
|
|
3338
|
+
const ownerDocument = typeof container === "string" ? globalThis.document : container.ownerDocument;
|
|
1850
3339
|
const containerEl = typeof container === "string"
|
|
1851
|
-
?
|
|
3340
|
+
? ownerDocument.getElementById(container)
|
|
1852
3341
|
: container;
|
|
1853
3342
|
if (!containerEl) {
|
|
1854
3343
|
throw new Error("Container element not found");
|