docxodus 7.1.0 → 9.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 +65 -24
- package/dist/docxodus.worker.js +2 -2
- package/dist/docxodus.worker.js.map +1 -1
- package/dist/editor-headerfooter.d.ts +166 -0
- package/dist/editor-headerfooter.d.ts.map +1 -0
- package/dist/editor-headerfooter.js +530 -0
- package/dist/editor-headerfooter.js.map +1 -0
- package/dist/editor-reconcile.d.ts +75 -0
- package/dist/editor-reconcile.d.ts.map +1 -0
- package/dist/editor-reconcile.js +125 -0
- package/dist/editor-reconcile.js.map +1 -0
- package/dist/editor.bundle.js +1861 -110
- package/dist/editor.d.ts +200 -6
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +868 -86
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/page-number-format.d.ts +22 -0
- package/dist/page-number-format.d.ts.map +1 -0
- package/dist/page-number-format.js +60 -0
- package/dist/page-number-format.js.map +1 -0
- package/dist/pagination.bundle.js +635 -37
- package/dist/pagination.d.ts +119 -0
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +706 -52
- package/dist/pagination.js.map +1 -1
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +6 -4
- package/dist/react.js.map +1 -1
- package/dist/session.bundle.js +241 -4
- package/dist/session.d.ts +160 -6
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +202 -4
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +238 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -11
- package/dist/types.js.map +1 -1
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
- package/dist/wasm/_framework/System.Console.wasm +0 -0
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Memory.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.wasm +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
- package/dist/wasm/_framework/System.wasm +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +41 -41
- package/dist/wasm/_framework/dotnet.js +1 -1
- package/dist/wasm/_framework/dotnet.js.map +1 -1
- package/dist/wasm/_framework/dotnet.native.js +3 -3
- package/dist/wasm/_framework/dotnet.native.js.symbols +2102 -2101
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.runtime.js +1 -1
- package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
- package/dist/wasm/index.html +2 -1
- package/package.json +17 -4
package/dist/pagination.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* This module provides client-side pagination that measures rendered content
|
|
5
5
|
* and flows it across fixed-size page containers based on document dimensions.
|
|
6
6
|
*/
|
|
7
|
+
import { formatPageNumber } from "./page-number-format.js";
|
|
7
8
|
// Default letter size in points (612 x 792 = 8.5" x 11")
|
|
8
9
|
const DEFAULT_PAGE_WIDTH = 612;
|
|
9
10
|
const DEFAULT_PAGE_HEIGHT = 792;
|
|
@@ -64,6 +65,8 @@ export class PaginationEngine {
|
|
|
64
65
|
*/
|
|
65
66
|
constructor(staging, container, options = {}) {
|
|
66
67
|
this.pendingFootnoteContinuation = null;
|
|
68
|
+
/** Per-section `w:pgNumType` (start / format), read off the section wrappers. */
|
|
69
|
+
this.pageNumbering = new Map();
|
|
67
70
|
this.stagingElement =
|
|
68
71
|
typeof staging === "string"
|
|
69
72
|
? document.getElementById(staging)
|
|
@@ -82,6 +85,7 @@ export class PaginationEngine {
|
|
|
82
85
|
this.cssPrefix = options.cssPrefix ?? "page-";
|
|
83
86
|
this.showPageNumbers = options.showPageNumbers ?? true;
|
|
84
87
|
this.pageGap = options.pageGap ?? 20;
|
|
88
|
+
this.fragmentParagraphs = options.fragmentParagraphs ?? false;
|
|
85
89
|
this.hfRegistry = new Map();
|
|
86
90
|
this.footnoteRegistry = new Map();
|
|
87
91
|
}
|
|
@@ -99,6 +103,7 @@ export class PaginationEngine {
|
|
|
99
103
|
this.footnoteRegistry = this.parseFootnoteRegistry();
|
|
100
104
|
// Find all section containers
|
|
101
105
|
const sections = this.stagingElement.querySelectorAll("[data-section-index]");
|
|
106
|
+
this.pageNumbering = this.parsePageNumbering(sections);
|
|
102
107
|
// If no sections found, treat the entire staging content as one section
|
|
103
108
|
const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
|
|
104
109
|
for (const section of sectionsToProcess) {
|
|
@@ -120,8 +125,65 @@ export class PaginationEngine {
|
|
|
120
125
|
}
|
|
121
126
|
// Hide staging after measurement
|
|
122
127
|
this.stagingElement.style.display = "none";
|
|
128
|
+
// Every page box exists now, so NUMPAGES has an answer and each PAGE marker knows its page.
|
|
129
|
+
this.substitutePageNumberFields(pages.length);
|
|
123
130
|
return { totalPages: pages.length, pages };
|
|
124
131
|
}
|
|
132
|
+
/** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */
|
|
133
|
+
parsePageNumbering(sections) {
|
|
134
|
+
const map = new Map();
|
|
135
|
+
for (const section of Array.from(sections)) {
|
|
136
|
+
const index = parseInt(section.dataset.sectionIndex || "0", 10);
|
|
137
|
+
const rawStart = section.dataset.pageNumStart;
|
|
138
|
+
const start = rawStart === undefined ? undefined : parseInt(rawStart, 10);
|
|
139
|
+
map.set(index, {
|
|
140
|
+
start: start !== undefined && Number.isFinite(start) ? start : undefined,
|
|
141
|
+
format: section.dataset.pageNumFmt,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return map;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Fill in the page-number fields inside every page's cloned header/footer.
|
|
148
|
+
*
|
|
149
|
+
* A header/footer is authored once and cloned onto each page, so a PAGE field's single cached
|
|
150
|
+
* result would otherwise show the same number on every page — the whole reason the converter
|
|
151
|
+
* marks these. `data-field-format` (the field's own `\*` switch) wins over the section's format
|
|
152
|
+
* when present, which is exactly how Word resolves the two.
|
|
153
|
+
*
|
|
154
|
+
* Runs after layout because NUMPAGES cannot be known before the last page exists. The
|
|
155
|
+
* substituted text can therefore be marginally wider than the cached result the header was
|
|
156
|
+
* measured with; the header band clips, so the failure mode is a hair of overflow rather than
|
|
157
|
+
* a layout that disagrees with itself.
|
|
158
|
+
*
|
|
159
|
+
* Scoped to the CLONED header/footer regions on purpose. A page-number field in body text is
|
|
160
|
+
* ordinary run content that the editor may make editable, and committing an edited block writes
|
|
161
|
+
* back whatever text the DOM holds — rewriting it here would mean a body field commits a number
|
|
162
|
+
* the document never contained. Body content is also not cloned, so it does not have the problem
|
|
163
|
+
* this method exists to solve.
|
|
164
|
+
*/
|
|
165
|
+
substitutePageNumberFields(totalPages) {
|
|
166
|
+
const boxes = this.containerElement.querySelectorAll(`.${this.cssPrefix}box`);
|
|
167
|
+
for (const box of Array.from(boxes)) {
|
|
168
|
+
const markers = box.querySelectorAll(`.${this.cssPrefix}header [data-field], .${this.cssPrefix}footer [data-field]`);
|
|
169
|
+
if (markers.length === 0)
|
|
170
|
+
continue;
|
|
171
|
+
const sectionIndex = parseInt(box.dataset.sectionIndex || "0", 10);
|
|
172
|
+
const pageNumber = parseInt(box.dataset.pageNumber || "1", 10);
|
|
173
|
+
const pageInSection = parseInt(box.dataset.pageInSection || "1", 10);
|
|
174
|
+
const numbering = this.pageNumbering.get(sectionIndex) ?? {};
|
|
175
|
+
// A section that restarts numbering counts from its own start; one that does not continues
|
|
176
|
+
// the document-wide running number.
|
|
177
|
+
const displayed = numbering.start !== undefined ? numbering.start + pageInSection - 1 : pageNumber;
|
|
178
|
+
for (const marker of Array.from(markers)) {
|
|
179
|
+
const kind = marker.dataset.field;
|
|
180
|
+
if (kind !== "PAGE" && kind !== "NUMPAGES")
|
|
181
|
+
continue;
|
|
182
|
+
const format = marker.dataset.fieldFormat ?? numbering.format;
|
|
183
|
+
marker.textContent = formatPageNumber(kind === "PAGE" ? displayed : totalPages, format);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
125
187
|
/**
|
|
126
188
|
* Measures all content blocks in a section.
|
|
127
189
|
*/
|
|
@@ -161,6 +223,443 @@ export class PaginationEngine {
|
|
|
161
223
|
}
|
|
162
224
|
return blocks;
|
|
163
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Measures one element in the same hidden staging context used for the source blocks.
|
|
228
|
+
* This is intentionally DOM-based: table row heights cannot be inferred from individual
|
|
229
|
+
* rows because wrapping and collapsed borders change the height of a fragment.
|
|
230
|
+
*/
|
|
231
|
+
measureElement(element, dims) {
|
|
232
|
+
const measurementHost = document.createElement("div");
|
|
233
|
+
measurementHost.style.position = "absolute";
|
|
234
|
+
measurementHost.style.visibility = "hidden";
|
|
235
|
+
measurementHost.style.left = "-9999px";
|
|
236
|
+
measurementHost.style.width = `${dims.contentWidth}pt`;
|
|
237
|
+
const measuredElement = element.cloneNode(true);
|
|
238
|
+
measurementHost.appendChild(measuredElement);
|
|
239
|
+
this.stagingElement.appendChild(measurementHost);
|
|
240
|
+
const rect = measuredElement.getBoundingClientRect();
|
|
241
|
+
const style = window.getComputedStyle(measuredElement);
|
|
242
|
+
const measured = {
|
|
243
|
+
element,
|
|
244
|
+
heightPt: pxToPt(rect.height),
|
|
245
|
+
marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
|
|
246
|
+
marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
|
|
247
|
+
keepWithNext: element.dataset.keepWithNext === "true",
|
|
248
|
+
keepLines: element.dataset.keepLines === "true",
|
|
249
|
+
pageBreakBefore: element.dataset.pageBreakBefore === "true",
|
|
250
|
+
isPageBreak: element.dataset.pageBreak === "true" ||
|
|
251
|
+
element.classList.contains(`${this.cssPrefix}break`),
|
|
252
|
+
};
|
|
253
|
+
this.stagingElement.removeChild(measurementHost);
|
|
254
|
+
return measured;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Returns the contiguous keep-with-next chain beginning at a block.
|
|
258
|
+
*
|
|
259
|
+
* A hard page break or a page-break-before directive is stronger than a
|
|
260
|
+
* keep-with-next directive, so it terminates the chain. The caller only
|
|
261
|
+
* keeps a chain together when the whole chain can fit on a fresh page.
|
|
262
|
+
*/
|
|
263
|
+
getKeepWithNextChain(blocks, startIndex) {
|
|
264
|
+
const firstBlock = blocks[startIndex];
|
|
265
|
+
if (!firstBlock)
|
|
266
|
+
return [];
|
|
267
|
+
const chain = [firstBlock];
|
|
268
|
+
let lastIndex = startIndex;
|
|
269
|
+
while (blocks[lastIndex].keepWithNext) {
|
|
270
|
+
const nextBlock = blocks[lastIndex + 1];
|
|
271
|
+
if (!nextBlock || nextBlock.isPageBreak || nextBlock.pageBreakBefore) {
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
chain.push(nextBlock);
|
|
275
|
+
lastIndex++;
|
|
276
|
+
}
|
|
277
|
+
return chain;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Measures the visible body height of a keep-with-next chain using the same
|
|
281
|
+
* collapsed-margin rules as normal block placement. The trailing margin is
|
|
282
|
+
* intentionally excluded, matching the individual block fit check.
|
|
283
|
+
*/
|
|
284
|
+
measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage) {
|
|
285
|
+
const firstBlock = chain[0];
|
|
286
|
+
if (!firstBlock)
|
|
287
|
+
return 0;
|
|
288
|
+
const firstMarginTop = isFirstOnPage
|
|
289
|
+
? firstBlock.marginTopPt
|
|
290
|
+
: Math.max(firstBlock.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
|
|
291
|
+
let bodyHeight = firstMarginTop + firstBlock.heightPt;
|
|
292
|
+
for (let index = 1; index < chain.length; index++) {
|
|
293
|
+
const previousBlock = chain[index - 1];
|
|
294
|
+
const block = chain[index];
|
|
295
|
+
bodyHeight += Math.max(previousBlock.marginBottomPt, block.marginTopPt) + block.heightPt;
|
|
296
|
+
}
|
|
297
|
+
return bodyHeight;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Finds footnote references introduced by a sequence of blocks, preserving
|
|
301
|
+
* document order and excluding references already assigned to the page.
|
|
302
|
+
*/
|
|
303
|
+
collectNewFootnoteIds(blocks, existingFootnoteIds) {
|
|
304
|
+
const knownIds = new Set(existingFootnoteIds);
|
|
305
|
+
const newIds = [];
|
|
306
|
+
for (const block of blocks) {
|
|
307
|
+
for (const id of this.extractFootnoteRefs(block.element)) {
|
|
308
|
+
if (!knownIds.has(id)) {
|
|
309
|
+
knownIds.add(id);
|
|
310
|
+
newIds.push(id);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return newIds;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The shortest body available to a section's first, default, or even page.
|
|
318
|
+
* A row fragment must fit every variant, otherwise a later header/footer could
|
|
319
|
+
* send it through the oversized-block fallback again.
|
|
320
|
+
*/
|
|
321
|
+
smallestEffectiveContentHeight(dims, sectionIndex) {
|
|
322
|
+
return Math.min(this.getEffectiveHeights(dims, sectionIndex, 1, 1).contentHeight, this.getEffectiveHeights(dims, sectionIndex, 2, 1).contentHeight, this.getEffectiveHeights(dims, sectionIndex, 2, 2).contentHeight);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Builds a clone of a simple table wrapper containing a contiguous run of rows.
|
|
326
|
+
* Complex table features are deliberately rejected by the caller: a split across
|
|
327
|
+
* merged cells, nested tables, or footnotes cannot be made correct by cloning rows.
|
|
328
|
+
*/
|
|
329
|
+
createSimpleTableFragment(wrapper, table, body, rows, retainAnchor) {
|
|
330
|
+
const wrapperClone = wrapper.cloneNode(false);
|
|
331
|
+
const tableClone = table.cloneNode(false);
|
|
332
|
+
// The eligibility gate permits only colgroups alongside the body. Keep each
|
|
333
|
+
// colgroup so fixed and proportional column widths remain stable per fragment.
|
|
334
|
+
for (const child of Array.from(table.children)) {
|
|
335
|
+
if (child !== body) {
|
|
336
|
+
tableClone.appendChild(child.cloneNode(true));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const bodyClone = body.cloneNode(false);
|
|
340
|
+
for (const row of rows) {
|
|
341
|
+
bodyClone.appendChild(row.cloneNode(true));
|
|
342
|
+
}
|
|
343
|
+
tableClone.appendChild(bodyClone);
|
|
344
|
+
if (!retainAnchor) {
|
|
345
|
+
wrapperClone.removeAttribute("data-anchor");
|
|
346
|
+
tableClone.removeAttribute("data-anchor");
|
|
347
|
+
}
|
|
348
|
+
wrapperClone.appendChild(tableClone);
|
|
349
|
+
return wrapperClone;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Splits an oversized, ordinary table at row boundaries. This only participates
|
|
353
|
+
* in the existing oversized-block fallback; unsupported tables keep the previous
|
|
354
|
+
* overflow behavior rather than risking broken table semantics.
|
|
355
|
+
*/
|
|
356
|
+
trySplitSimpleOversizedTable(block, dims, sectionIndex) {
|
|
357
|
+
const wrapper = block.element;
|
|
358
|
+
if (wrapper.tagName !== "DIV" ||
|
|
359
|
+
wrapper.children.length !== 1 ||
|
|
360
|
+
block.keepWithNext ||
|
|
361
|
+
block.keepLines ||
|
|
362
|
+
block.pageBreakBefore ||
|
|
363
|
+
block.isPageBreak) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const table = wrapper.firstElementChild;
|
|
367
|
+
if (!(table instanceof HTMLTableElement)) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
const body = table.tBodies.length === 1 ? table.tBodies[0] : null;
|
|
371
|
+
if (!body ||
|
|
372
|
+
table.tHead ||
|
|
373
|
+
table.tFoot ||
|
|
374
|
+
body.rows.length < 2 ||
|
|
375
|
+
Array.from(table.children).some(child => child !== body && child.tagName !== "COLGROUP") ||
|
|
376
|
+
table.querySelector("table, [rowspan], [colspan], [data-footnote-id]") ||
|
|
377
|
+
wrapper.querySelector("[data-footnote-id]")) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const rows = Array.from(body.rows);
|
|
381
|
+
const minimumContentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
|
|
382
|
+
// Use the source's full vertical margins while forming groups. Continuation
|
|
383
|
+
// fragments later clear their joining margins, so this conservative bound
|
|
384
|
+
// cannot create a fragment that overflows a header/footer variant.
|
|
385
|
+
const maximumFragmentHeight = minimumContentHeight - block.marginTopPt - block.marginBottomPt;
|
|
386
|
+
if (maximumFragmentHeight <= 0) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const groups = [];
|
|
390
|
+
let start = 0;
|
|
391
|
+
while (start < rows.length) {
|
|
392
|
+
let end = start;
|
|
393
|
+
while (end < rows.length) {
|
|
394
|
+
const candidate = this.createSimpleTableFragment(wrapper, table, body, rows.slice(start, end + 1), start === 0);
|
|
395
|
+
const measured = this.measureElement(candidate, dims);
|
|
396
|
+
if (measured.heightPt > maximumFragmentHeight) {
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
end++;
|
|
400
|
+
}
|
|
401
|
+
// Even a one-row fragment cannot fit. Preserve the established overflow
|
|
402
|
+
// fallback rather than looping or clipping a partially split row.
|
|
403
|
+
if (end === start) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
groups.push(rows.slice(start, end));
|
|
407
|
+
start = end;
|
|
408
|
+
}
|
|
409
|
+
if (groups.length < 2) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
const fragments = [];
|
|
413
|
+
for (let index = 0; index < groups.length; index++) {
|
|
414
|
+
const isFirst = index === 0;
|
|
415
|
+
const isLast = index === groups.length - 1;
|
|
416
|
+
const fragment = this.createSimpleTableFragment(wrapper, table, body, groups[index], isFirst);
|
|
417
|
+
// Keep the source's outer spacing only at the table's real boundaries.
|
|
418
|
+
// Continuation margins would otherwise add blank space at the top/bottom
|
|
419
|
+
// of every paginated fragment.
|
|
420
|
+
if (!isFirst) {
|
|
421
|
+
fragment.style.setProperty("margin-top", "0", "important");
|
|
422
|
+
}
|
|
423
|
+
if (!isLast) {
|
|
424
|
+
fragment.style.setProperty("margin-bottom", "0", "important");
|
|
425
|
+
}
|
|
426
|
+
const measured = this.measureElement(fragment, dims);
|
|
427
|
+
if (measured.heightPt + measured.marginTopPt + measured.marginBottomPt >
|
|
428
|
+
minimumContentHeight) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
fragments.push({
|
|
432
|
+
...measured,
|
|
433
|
+
keepWithNext: false,
|
|
434
|
+
keepLines: false,
|
|
435
|
+
pageBreakBefore: false,
|
|
436
|
+
isPageBreak: false,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return fragments;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* A DOM endpoint that can finish a paragraph fragment. Endpoints are chosen
|
|
443
|
+
* after whitespace or at a run boundary so the paginator never deliberately
|
|
444
|
+
* cuts through an ordinary word merely to fill a little more of a page.
|
|
445
|
+
*/
|
|
446
|
+
paragraphFragmentEndpoints(paragraph) {
|
|
447
|
+
const endpoints = [];
|
|
448
|
+
const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
|
|
449
|
+
let textNode;
|
|
450
|
+
while ((textNode = walker.nextNode())) {
|
|
451
|
+
const text = textNode.data;
|
|
452
|
+
if (text.length === 0)
|
|
453
|
+
continue;
|
|
454
|
+
// A whitespace boundary preserves normal word wrapping. Always retain the
|
|
455
|
+
// end of a run too: adjacent runs can change formatting without containing
|
|
456
|
+
// a whitespace character between them.
|
|
457
|
+
const whitespace = /\s+/g;
|
|
458
|
+
let match;
|
|
459
|
+
while ((match = whitespace.exec(text)) !== null) {
|
|
460
|
+
endpoints.push({ node: textNode, offset: match.index + match[0].length });
|
|
461
|
+
}
|
|
462
|
+
if (endpoints.length === 0 || endpoints[endpoints.length - 1].node !== textNode ||
|
|
463
|
+
endpoints[endpoints.length - 1].offset !== text.length) {
|
|
464
|
+
endpoints.push({ node: textNode, offset: text.length });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return endpoints;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Whether a range contains visible text after ignoring bidi/zero-width marks.
|
|
471
|
+
* Paragraph fragmentation deliberately excludes non-textual descendants, so
|
|
472
|
+
* this is enough to reject empty head or tail fragments.
|
|
473
|
+
*/
|
|
474
|
+
hasVisibleFragmentText(fragment) {
|
|
475
|
+
return (fragment.textContent || "")
|
|
476
|
+
.replace(/[\u200B-\u200F\uFEFF]/g, "")
|
|
477
|
+
.trim()
|
|
478
|
+
.length > 0;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Phase one only handles text-like paragraph descendants. Objects, explicit
|
|
482
|
+
* line breaks, list markers, notes, and out-of-flow/inline-block content all
|
|
483
|
+
* require their own line-layout rules and retain the established whole-block
|
|
484
|
+
* fallback instead of risking broken content or duplicate anchors.
|
|
485
|
+
*/
|
|
486
|
+
canFragmentParagraph(block) {
|
|
487
|
+
if (!this.fragmentParagraphs || block.element.tagName !== "P") {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
const paragraph = block.element;
|
|
491
|
+
if (block.keepWithNext ||
|
|
492
|
+
block.keepLines ||
|
|
493
|
+
block.pageBreakBefore ||
|
|
494
|
+
block.isPageBreak ||
|
|
495
|
+
paragraph.dataset.widowControl === "true" ||
|
|
496
|
+
paragraph.hasAttribute("contenteditable")) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
// The outer paragraph may carry its source identity. Descendant identities
|
|
500
|
+
// are not safe to duplicate in a continuation fragment, so reject them.
|
|
501
|
+
const unsupportedDescendants = [
|
|
502
|
+
"br",
|
|
503
|
+
"img",
|
|
504
|
+
"picture",
|
|
505
|
+
"svg",
|
|
506
|
+
"math",
|
|
507
|
+
"canvas",
|
|
508
|
+
"video",
|
|
509
|
+
"audio",
|
|
510
|
+
"iframe",
|
|
511
|
+
"object",
|
|
512
|
+
"embed",
|
|
513
|
+
"input",
|
|
514
|
+
"button",
|
|
515
|
+
"select",
|
|
516
|
+
"textarea",
|
|
517
|
+
"table",
|
|
518
|
+
"ol",
|
|
519
|
+
"ul",
|
|
520
|
+
"li",
|
|
521
|
+
"dl",
|
|
522
|
+
"div",
|
|
523
|
+
"p",
|
|
524
|
+
"section",
|
|
525
|
+
"article",
|
|
526
|
+
"aside",
|
|
527
|
+
"figure",
|
|
528
|
+
"fieldset",
|
|
529
|
+
"details",
|
|
530
|
+
"[data-footnote-id]",
|
|
531
|
+
"[data-list-marker]",
|
|
532
|
+
"[data-anchor]",
|
|
533
|
+
"[id]",
|
|
534
|
+
"[contenteditable]",
|
|
535
|
+
].join(", ");
|
|
536
|
+
if (paragraph.querySelector(unsupportedDescendants)) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
const paragraphStyle = window.getComputedStyle(paragraph);
|
|
540
|
+
if (paragraphStyle.display !== "block" ||
|
|
541
|
+
paragraphStyle.position !== "static" ||
|
|
542
|
+
paragraphStyle.float !== "none" ||
|
|
543
|
+
paragraphStyle.whiteSpace !== "normal" ||
|
|
544
|
+
paragraphStyle.breakBefore !== "auto" ||
|
|
545
|
+
paragraphStyle.breakAfter !== "auto" ||
|
|
546
|
+
paragraphStyle.breakInside === "avoid" ||
|
|
547
|
+
paragraphStyle.pageBreakBefore !== "auto" ||
|
|
548
|
+
paragraphStyle.pageBreakAfter !== "auto" ||
|
|
549
|
+
paragraphStyle.pageBreakInside === "avoid") {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
// A range clone preserves nested inline formatting exactly. Anything that
|
|
553
|
+
// establishes its own box/layout context is intentionally deferred until a
|
|
554
|
+
// future fragmenter can model it accurately.
|
|
555
|
+
for (const descendant of Array.from(paragraph.querySelectorAll("*"))) {
|
|
556
|
+
const style = window.getComputedStyle(descendant);
|
|
557
|
+
if (style.display !== "inline" ||
|
|
558
|
+
style.position !== "static" ||
|
|
559
|
+
style.float !== "none" ||
|
|
560
|
+
style.whiteSpace !== "normal") {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Builds one range-cloned paragraph fragment. Only the leading fragment keeps
|
|
568
|
+
* the source paragraph's addressability; continuations must not duplicate an
|
|
569
|
+
* id/data-anchor in the rendered document.
|
|
570
|
+
*/
|
|
571
|
+
createParagraphFragment(paragraph, range, retainSourceIdentity, isFinalFragment) {
|
|
572
|
+
const fragment = paragraph.cloneNode(false);
|
|
573
|
+
const contents = range.cloneContents();
|
|
574
|
+
fragment.appendChild(contents);
|
|
575
|
+
if (!retainSourceIdentity) {
|
|
576
|
+
fragment.removeAttribute("id");
|
|
577
|
+
fragment.removeAttribute("data-anchor");
|
|
578
|
+
// A continuation starts with a normal line rather than repeating a first-
|
|
579
|
+
// line/hanging indent. Keep the side margin so paragraph alignment remains.
|
|
580
|
+
fragment.style.setProperty("margin-top", "0", "important");
|
|
581
|
+
fragment.style.setProperty("text-indent", "0", "important");
|
|
582
|
+
}
|
|
583
|
+
if (!isFinalFragment) {
|
|
584
|
+
// The original bottom spacing belongs after the complete paragraph, not
|
|
585
|
+
// between synthetic page fragments.
|
|
586
|
+
fragment.style.setProperty("margin-bottom", "0", "important");
|
|
587
|
+
}
|
|
588
|
+
return fragment;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Splits a simple paragraph at the largest DOM Range endpoint that fits the
|
|
592
|
+
* currently available body space. The caller then processes the tail normally,
|
|
593
|
+
* allowing it to fragment again on later pages when necessary.
|
|
594
|
+
*/
|
|
595
|
+
tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
|
|
596
|
+
if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
const paragraph = block.element;
|
|
600
|
+
const endpoints = this.paragraphFragmentEndpoints(paragraph);
|
|
601
|
+
// The final endpoint is the full paragraph and cannot leave a tail. A
|
|
602
|
+
// single text run with no earlier whitespace remains an overflow fallback.
|
|
603
|
+
if (endpoints.length < 2) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
let low = 0;
|
|
607
|
+
let high = endpoints.length - 2;
|
|
608
|
+
let best = null;
|
|
609
|
+
// Fragment height is monotonic for the deliberately narrow eligible subset,
|
|
610
|
+
// so binary search avoids measuring every word in long body paragraphs.
|
|
611
|
+
while (low <= high) {
|
|
612
|
+
const middle = Math.floor((low + high) / 2);
|
|
613
|
+
const endpoint = endpoints[middle];
|
|
614
|
+
const headRange = document.createRange();
|
|
615
|
+
headRange.setStart(paragraph, 0);
|
|
616
|
+
headRange.setEnd(endpoint.node, endpoint.offset);
|
|
617
|
+
const headContents = headRange.cloneContents();
|
|
618
|
+
if (!this.hasVisibleFragmentText(headContents)) {
|
|
619
|
+
low = middle + 1;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const head = this.createParagraphFragment(paragraph, headRange, true, false);
|
|
623
|
+
const measured = this.measureElement(head, dims);
|
|
624
|
+
if (effectiveMarginTopPt + measured.heightPt <= availableHeightPt) {
|
|
625
|
+
best = { endpoint, element: head, measured };
|
|
626
|
+
low = middle + 1;
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
high = middle - 1;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (!best) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
const tailRange = document.createRange();
|
|
636
|
+
tailRange.setStart(best.endpoint.node, best.endpoint.offset);
|
|
637
|
+
tailRange.setEnd(paragraph, paragraph.childNodes.length);
|
|
638
|
+
const tailContents = tailRange.cloneContents();
|
|
639
|
+
if (!this.hasVisibleFragmentText(tailContents)) {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
|
|
643
|
+
const tailMeasured = this.measureElement(tail, dims);
|
|
644
|
+
return [
|
|
645
|
+
{
|
|
646
|
+
...best.measured,
|
|
647
|
+
element: best.element,
|
|
648
|
+
keepWithNext: false,
|
|
649
|
+
keepLines: false,
|
|
650
|
+
pageBreakBefore: false,
|
|
651
|
+
isPageBreak: false,
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
...tailMeasured,
|
|
655
|
+
element: tail,
|
|
656
|
+
keepWithNext: false,
|
|
657
|
+
keepLines: false,
|
|
658
|
+
pageBreakBefore: false,
|
|
659
|
+
isPageBreak: false,
|
|
660
|
+
},
|
|
661
|
+
];
|
|
662
|
+
}
|
|
164
663
|
/**
|
|
165
664
|
* Parses the header/footer registry from the staging element.
|
|
166
665
|
* Also measures heights during parsing for lazy-loading compatibility.
|
|
@@ -267,12 +766,16 @@ export class PaginationEngine {
|
|
|
267
766
|
if ((footnoteIds.length === 0 && !hasContinuation) || this.footnoteRegistry.size === 0) {
|
|
268
767
|
return 0;
|
|
269
768
|
}
|
|
769
|
+
// Measure in the SAME styling context the notes render in: `.page-footnotes` carries
|
|
770
|
+
// font-size 0.85em and line-height 1.4, so measuring without the class sizes the note
|
|
771
|
+
// block against body type and the reserve can never match what is drawn.
|
|
270
772
|
// Create a temporary measurement container
|
|
271
773
|
const measureContainer = document.createElement("div");
|
|
272
774
|
measureContainer.style.position = "absolute";
|
|
273
775
|
measureContainer.style.visibility = "hidden";
|
|
274
776
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
275
777
|
measureContainer.style.left = "-9999px";
|
|
778
|
+
measureContainer.className = this.cssPrefix + "footnotes";
|
|
276
779
|
// Add separator line (same as will be rendered)
|
|
277
780
|
const hr = document.createElement("hr");
|
|
278
781
|
measureContainer.appendChild(hr);
|
|
@@ -313,6 +816,7 @@ export class PaginationEngine {
|
|
|
313
816
|
measureContainer.style.visibility = "hidden";
|
|
314
817
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
315
818
|
measureContainer.style.left = "-9999px";
|
|
819
|
+
measureContainer.className = this.cssPrefix + "footnotes";
|
|
316
820
|
// Add separator line
|
|
317
821
|
const hr = document.createElement("hr");
|
|
318
822
|
measureContainer.appendChild(hr);
|
|
@@ -331,16 +835,29 @@ export class PaginationEngine {
|
|
|
331
835
|
* Returns the elements that fit and the elements that need to continue.
|
|
332
836
|
*/
|
|
333
837
|
splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth) {
|
|
334
|
-
// Get child elements (paragraphs) of the footnote content
|
|
838
|
+
// Get child elements (paragraphs) of the footnote content.
|
|
839
|
+
//
|
|
840
|
+
// `fits` is spliced into a freshly built `.footnote-item` > `.footnote-content` wrapper by
|
|
841
|
+
// addPageFootnotes, so it must contain the note's CONTENT, never the note element itself.
|
|
842
|
+
// Returning the whole `.footnote-item` here nested a complete item (number span and all)
|
|
843
|
+
// inside another item's content span; the inner block-level div then broke the line, so the
|
|
844
|
+
// note's number rendered alone above its text — the same visible symptom as the escaped-CSS
|
|
845
|
+
// bug, from an unrelated cause, on the notes that happened to take a can't-split path.
|
|
335
846
|
const footnoteContent = footnoteElement.querySelector(".footnote-content");
|
|
336
847
|
if (!footnoteContent) {
|
|
337
|
-
// No content structure
|
|
338
|
-
return {
|
|
848
|
+
// No content structure to split — hand back the element's own children.
|
|
849
|
+
return {
|
|
850
|
+
fits: Array.from(footnoteElement.children).map((el) => el.cloneNode(true)),
|
|
851
|
+
overflow: [],
|
|
852
|
+
};
|
|
339
853
|
}
|
|
340
854
|
const children = Array.from(footnoteContent.children);
|
|
341
855
|
if (children.length <= 1) {
|
|
342
|
-
// Single paragraph
|
|
343
|
-
return {
|
|
856
|
+
// Single paragraph: can't split at paragraph level, but the whole content still fits.
|
|
857
|
+
return {
|
|
858
|
+
fits: children.map((el) => el.cloneNode(true)),
|
|
859
|
+
overflow: [],
|
|
860
|
+
};
|
|
344
861
|
}
|
|
345
862
|
const fits = [];
|
|
346
863
|
const overflow = [];
|
|
@@ -351,6 +868,7 @@ export class PaginationEngine {
|
|
|
351
868
|
hrMeasure.style.visibility = "hidden";
|
|
352
869
|
hrMeasure.style.width = `${contentWidth}pt`;
|
|
353
870
|
hrMeasure.style.left = "-9999px";
|
|
871
|
+
hrMeasure.className = this.cssPrefix + "footnotes";
|
|
354
872
|
const hr = document.createElement("hr");
|
|
355
873
|
hrMeasure.appendChild(hr);
|
|
356
874
|
this.stagingElement.appendChild(hrMeasure);
|
|
@@ -367,6 +885,7 @@ export class PaginationEngine {
|
|
|
367
885
|
measureContainer.style.visibility = "hidden";
|
|
368
886
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
369
887
|
measureContainer.style.left = "-9999px";
|
|
888
|
+
measureContainer.className = this.cssPrefix + "footnotes";
|
|
370
889
|
measureContainer.appendChild(child.cloneNode(true));
|
|
371
890
|
this.stagingElement.appendChild(measureContainer);
|
|
372
891
|
const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
|
|
@@ -397,6 +916,7 @@ export class PaginationEngine {
|
|
|
397
916
|
measureContainer.style.visibility = "hidden";
|
|
398
917
|
measureContainer.style.width = `${contentWidth}pt`;
|
|
399
918
|
measureContainer.style.left = "-9999px";
|
|
919
|
+
measureContainer.className = this.cssPrefix + "footnotes";
|
|
400
920
|
measureContainer.appendChild(footnote.cloneNode(true));
|
|
401
921
|
this.stagingElement.appendChild(measureContainer);
|
|
402
922
|
const rect = measureContainer.getBoundingClientRect();
|
|
@@ -618,6 +1138,18 @@ export class PaginationEngine {
|
|
|
618
1138
|
let currentContinuation = this.pendingFootnoteContinuation;
|
|
619
1139
|
// Track any new continuation that will carry to next page
|
|
620
1140
|
let nextPageContinuation = null;
|
|
1141
|
+
/**
|
|
1142
|
+
* Whole notes that could not be started on this page and must render on the next one.
|
|
1143
|
+
*
|
|
1144
|
+
* `nextPageContinuation` is a single slot, but the code that fills it runs once per note in a
|
|
1145
|
+
* page's citation list — so when two notes on the same page both failed to fit, the second
|
|
1146
|
+
* overwrote the first and that note was never rendered anywhere. On a 94-footnote document
|
|
1147
|
+
* four notes disappeared from the output entirely. A deferral queue keeps the single-slot
|
|
1148
|
+
* continuation for its real meaning (the tail of a note that was SPLIT) and carries
|
|
1149
|
+
* never-started notes forward as ordinary footnote ids, which the next page already knows how
|
|
1150
|
+
* to lay out.
|
|
1151
|
+
*/
|
|
1152
|
+
let deferredFootnoteIds = [];
|
|
621
1153
|
// Track partial footnotes for current page (footnotes that were split)
|
|
622
1154
|
let currentPartialFootnotes = [];
|
|
623
1155
|
// Account for any continuation from previous section/page
|
|
@@ -625,7 +1157,8 @@ export class PaginationEngine {
|
|
|
625
1157
|
currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
|
|
626
1158
|
}
|
|
627
1159
|
const finishPage = () => {
|
|
628
|
-
|
|
1160
|
+
const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0;
|
|
1161
|
+
if (currentContent.length === 0 && !hasCurrentContinuation)
|
|
629
1162
|
return;
|
|
630
1163
|
const page = this.createPage(dims, pageNumber, sectionIndex, currentContent, pageInSection, currentFootnoteIds, currentFootnoteHeight, currentContinuation, currentPartialFootnotes.length > 0 ? currentPartialFootnotes : undefined);
|
|
631
1164
|
pages.push(page);
|
|
@@ -642,6 +1175,13 @@ export class PaginationEngine {
|
|
|
642
1175
|
// Carry over continuation to next page
|
|
643
1176
|
currentContinuation = nextPageContinuation;
|
|
644
1177
|
nextPageContinuation = null;
|
|
1178
|
+
// Notes that never got started land at the top of the new page's note area. They are
|
|
1179
|
+
// ordinary footnotes from here on, so the normal fitting path handles them — and because
|
|
1180
|
+
// this page is fresh, the space they were denied now exists.
|
|
1181
|
+
if (deferredFootnoteIds.length > 0) {
|
|
1182
|
+
currentFootnoteIds = [...deferredFootnoteIds];
|
|
1183
|
+
deferredFootnoteIds = [];
|
|
1184
|
+
}
|
|
645
1185
|
// Account for continuation height on new page
|
|
646
1186
|
if (currentContinuation && currentContinuation.remainingElements.length > 0) {
|
|
647
1187
|
currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
|
|
@@ -649,10 +1189,12 @@ export class PaginationEngine {
|
|
|
649
1189
|
else {
|
|
650
1190
|
currentFootnoteHeight = 0;
|
|
651
1191
|
}
|
|
1192
|
+
if (currentFootnoteIds.length > 0) {
|
|
1193
|
+
currentFootnoteHeight += this.measureFootnotesHeight(currentFootnoteIds, dims.contentWidth, null);
|
|
1194
|
+
}
|
|
652
1195
|
};
|
|
653
1196
|
for (let i = 0; i < blocks.length; i++) {
|
|
654
1197
|
const block = blocks[i];
|
|
655
|
-
const nextBlock = blocks[i + 1];
|
|
656
1198
|
// Handle explicit page breaks
|
|
657
1199
|
if (block.isPageBreak) {
|
|
658
1200
|
finishPage();
|
|
@@ -662,10 +1204,50 @@ export class PaginationEngine {
|
|
|
662
1204
|
if (block.pageBreakBefore && currentContent.length > 0) {
|
|
663
1205
|
finishPage();
|
|
664
1206
|
}
|
|
1207
|
+
// A series of keep-with-next blocks is one indivisible placement unit
|
|
1208
|
+
// when it can fit a new page. The former one-block lookahead was never
|
|
1209
|
+
// applied to the placement decision, and it could not preserve a chain of
|
|
1210
|
+
// headings/paragraphs. Do not force an oversized chain to a fresh page:
|
|
1211
|
+
// its members retain the established greedy/overflow behavior instead.
|
|
1212
|
+
const previousBlock = blocks[i - 1];
|
|
1213
|
+
const startsKeepChain = !previousBlock ||
|
|
1214
|
+
!previousBlock.keepWithNext ||
|
|
1215
|
+
previousBlock.isPageBreak ||
|
|
1216
|
+
block.pageBreakBefore;
|
|
1217
|
+
// A footnote continuation also occupies this page even when its body has
|
|
1218
|
+
// no blocks yet. In that case a feasible chain may need to move past the
|
|
1219
|
+
// continuation as a unit.
|
|
1220
|
+
const pageHasOccupiedSpace = currentContent.length > 0 || currentFootnoteHeight > 0;
|
|
1221
|
+
if (pageHasOccupiedSpace && block.keepWithNext && startsKeepChain) {
|
|
1222
|
+
const keepChain = this.getKeepWithNextChain(blocks, i);
|
|
1223
|
+
if (keepChain.length > 1) {
|
|
1224
|
+
const newChainFootnoteIds = this.collectNewFootnoteIds(keepChain, currentFootnoteIds);
|
|
1225
|
+
let additionalChainFootnoteHeight = 0;
|
|
1226
|
+
if (newChainFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
|
|
1227
|
+
const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds], dims.contentWidth, currentContinuation);
|
|
1228
|
+
additionalChainFootnoteHeight = Math.max(0, totalChainFootnoteHeight - currentFootnoteHeight);
|
|
1229
|
+
}
|
|
1230
|
+
const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0) +
|
|
1231
|
+
additionalChainFootnoteHeight;
|
|
1232
|
+
const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
|
|
1233
|
+
if (currentChainHeight > currentAvailableHeight) {
|
|
1234
|
+
const nextPageHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection + 1, pageNumber + 1);
|
|
1235
|
+
const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true);
|
|
1236
|
+
// finishPage transfers this continuation to the new page's
|
|
1237
|
+
// currentContinuation state, so include it in the destination
|
|
1238
|
+
// page's footnote reservation before deciding to move the chain.
|
|
1239
|
+
const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds, dims.contentWidth, nextPageContinuation);
|
|
1240
|
+
if (freshChainBodyHeight + freshChainFootnoteHeight <=
|
|
1241
|
+
nextPageHeights.contentHeight) {
|
|
1242
|
+
finishPage();
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
665
1247
|
// Extract footnote references from this block
|
|
666
|
-
const
|
|
1248
|
+
const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
|
|
667
1249
|
// Only count new footnotes (not already on this page)
|
|
668
|
-
const newFootnoteIds =
|
|
1250
|
+
const newFootnoteIds = this.collectNewFootnoteIds([block], currentFootnoteIds);
|
|
669
1251
|
// Calculate additional footnote height if this block is added
|
|
670
1252
|
let additionalFootnoteHeight = 0;
|
|
671
1253
|
if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
|
|
@@ -688,20 +1270,24 @@ export class PaginationEngine {
|
|
|
688
1270
|
// bottom margin extends beyond the content area and is clipped by overflow:hidden.
|
|
689
1271
|
// It is still tracked in remainingHeight for correct margin collapsing with the next block.
|
|
690
1272
|
const blockSpace = effectiveMarginTop + block.heightPt + additionalFootnoteHeight;
|
|
691
|
-
// Calculate needed height (including keepWithNext)
|
|
692
|
-
let neededHeight = blockSpace;
|
|
693
|
-
if (block.keepWithNext && nextBlock && !nextBlock.isPageBreak) {
|
|
694
|
-
// For keepWithNext, include the next block with collapsed margins
|
|
695
|
-
const collapsedMargin = Math.max(block.marginBottomPt, nextBlock.marginTopPt);
|
|
696
|
-
neededHeight = effectiveMarginTop + block.heightPt + collapsedMargin +
|
|
697
|
-
nextBlock.heightPt + additionalFootnoteHeight;
|
|
698
|
-
}
|
|
699
1273
|
// Effective remaining height (content area minus footnotes already on page)
|
|
700
1274
|
const effectiveRemainingHeight = remainingHeight - currentFootnoteHeight;
|
|
701
1275
|
// Calculate maximum footnote area for this page (can expand into body content space)
|
|
702
1276
|
const bodyContentUsed = effectiveContentHeight - remainingHeight;
|
|
703
1277
|
const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
|
|
704
1278
|
const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
|
|
1279
|
+
// A paragraph that cannot fit as a whole may still have a simple text-only
|
|
1280
|
+
// prefix that fits this page. Fragment before the ordinary next-page or
|
|
1281
|
+
// oversized fallback so the cloned head participates in the same margin
|
|
1282
|
+
// and footnote accounting as every other block.
|
|
1283
|
+
if (blockSpace > effectiveRemainingHeight) {
|
|
1284
|
+
const paragraphFragments = this.tryFragmentParagraph(block, dims, effectiveRemainingHeight, effectiveMarginTop);
|
|
1285
|
+
if (paragraphFragments) {
|
|
1286
|
+
blocks.splice(i, 1, ...paragraphFragments);
|
|
1287
|
+
i--;
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
705
1291
|
// Check if block fits on current page (including its footnotes)
|
|
706
1292
|
if (blockSpace <= effectiveRemainingHeight) {
|
|
707
1293
|
// Block fits with current footnote allocation
|
|
@@ -752,34 +1338,23 @@ export class PaginationEngine {
|
|
|
752
1338
|
footnoteId,
|
|
753
1339
|
fittingElements: fits
|
|
754
1340
|
});
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1341
|
+
if (overflow.length > 0) {
|
|
1342
|
+
nextPageContinuation = {
|
|
1343
|
+
footnoteId,
|
|
1344
|
+
remainingElements: overflow
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
759
1347
|
currentFootnoteHeight = availableForFootnotes;
|
|
760
1348
|
}
|
|
761
1349
|
else {
|
|
762
|
-
// Nothing fits
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *"))
|
|
766
|
-
.map(el => el.cloneNode(true))
|
|
767
|
-
};
|
|
768
|
-
if (nextPageContinuation.remainingElements.length === 0) {
|
|
769
|
-
nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
|
|
770
|
-
}
|
|
1350
|
+
// Nothing of this note fits: defer the WHOLE note rather than assigning the
|
|
1351
|
+
// single continuation slot, which a later note on this page would overwrite.
|
|
1352
|
+
deferredFootnoteIds.push(footnoteId);
|
|
771
1353
|
}
|
|
772
1354
|
}
|
|
773
1355
|
else {
|
|
774
|
-
// Not enough space to start
|
|
775
|
-
|
|
776
|
-
footnoteId,
|
|
777
|
-
remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *"))
|
|
778
|
-
.map(el => el.cloneNode(true))
|
|
779
|
-
};
|
|
780
|
-
if (nextPageContinuation.remainingElements.length === 0) {
|
|
781
|
-
nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
|
|
782
|
-
}
|
|
1356
|
+
// Not enough space to even start the note — same deferral.
|
|
1357
|
+
deferredFootnoteIds.push(footnoteId);
|
|
783
1358
|
}
|
|
784
1359
|
}
|
|
785
1360
|
}
|
|
@@ -792,8 +1367,14 @@ export class PaginationEngine {
|
|
|
792
1367
|
const expandedFootnoteSpace = Math.min(maxFootnoteArea, additionalFootnoteHeight + currentFootnoteHeight);
|
|
793
1368
|
const bodySpaceAfterExpansion = effectiveContentHeight - expandedFootnoteSpace;
|
|
794
1369
|
if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) {
|
|
795
|
-
// Block fits after expanding footnote area
|
|
1370
|
+
// Block fits after expanding footnote area.
|
|
796
1371
|
currentContent.push(block.element.cloneNode(true));
|
|
1372
|
+
// `remainingHeight` tracks BODY consumption only — every other branch maintains it
|
|
1373
|
+
// that way, and the footnote reserve is applied separately via `effectiveRemainingHeight`
|
|
1374
|
+
// at the top of each iteration. Assigning `bodySpaceAfterExpansion - …` here folded the
|
|
1375
|
+
// reserve in a second time, so a later block would see a body budget short by the whole
|
|
1376
|
+
// footnote area. Consistency fix: no measurable difference on the documents tested, but
|
|
1377
|
+
// the two meanings must not coexist or the next change here inherits a latent bug.
|
|
797
1378
|
remainingHeight = bodySpaceAfterExpansion - bodyContentUsed - blockSpaceWithoutFootnotes;
|
|
798
1379
|
prevMarginBottomPt = block.marginBottomPt;
|
|
799
1380
|
currentFootnoteIds.push(...newFootnoteIds);
|
|
@@ -802,14 +1383,16 @@ export class PaginationEngine {
|
|
|
802
1383
|
else {
|
|
803
1384
|
// Still doesn't fit - start new page
|
|
804
1385
|
finishPage();
|
|
805
|
-
const newPageFootnoteHeight =
|
|
806
|
-
? this.measureFootnotesHeight(
|
|
1386
|
+
const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
|
|
1387
|
+
? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
|
|
807
1388
|
: (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
|
|
808
1389
|
const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
|
|
809
1390
|
currentContent.push(block.element.cloneNode(true));
|
|
810
1391
|
remainingHeight = effectiveContentHeight - newPageSpace;
|
|
811
1392
|
prevMarginBottomPt = block.marginBottomPt;
|
|
812
|
-
|
|
1393
|
+
// Merge, never replace: finishPage() may have just seeded this page with notes deferred
|
|
1394
|
+
// from the previous one, and overwriting here dropped them from the document.
|
|
1395
|
+
currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
|
|
813
1396
|
currentFootnoteHeight = newPageFootnoteHeight;
|
|
814
1397
|
}
|
|
815
1398
|
}
|
|
@@ -818,26 +1401,42 @@ export class PaginationEngine {
|
|
|
818
1401
|
finishPage();
|
|
819
1402
|
// On new page, recalculate footnote height for just this block's footnotes
|
|
820
1403
|
// (plus any continuation from previous page)
|
|
821
|
-
const newPageFootnoteHeight =
|
|
822
|
-
? this.measureFootnotesHeight(
|
|
1404
|
+
const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
|
|
1405
|
+
? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
|
|
823
1406
|
: (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
|
|
824
1407
|
// Include full top margin
|
|
825
1408
|
const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
|
|
826
1409
|
currentContent.push(block.element.cloneNode(true));
|
|
827
1410
|
remainingHeight = effectiveContentHeight - newPageSpace;
|
|
828
1411
|
prevMarginBottomPt = block.marginBottomPt;
|
|
829
|
-
|
|
1412
|
+
// Merge, never replace: finishPage() may have just seeded this page with notes deferred
|
|
1413
|
+
// from the previous one, and overwriting here dropped them from the document.
|
|
1414
|
+
currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
|
|
830
1415
|
currentFootnoteHeight = newPageFootnoteHeight;
|
|
831
1416
|
}
|
|
832
1417
|
}
|
|
833
1418
|
else {
|
|
834
|
-
// Block is taller than a page
|
|
835
|
-
//
|
|
1419
|
+
// Block is taller than a page. Ordinary tables can be split at complete
|
|
1420
|
+
// row boundaries; every other block retains the established overflow path.
|
|
1421
|
+
const tableFragments = this.trySplitSimpleOversizedTable(block, dims, sectionIndex);
|
|
1422
|
+
if (tableFragments) {
|
|
1423
|
+
if (currentContent.length > 0 || currentContinuation) {
|
|
1424
|
+
finishPage();
|
|
1425
|
+
}
|
|
1426
|
+
blocks.splice(i, 1, ...tableFragments);
|
|
1427
|
+
i--;
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
// Unsupported oversized blocks are intentionally left intact. Splitting
|
|
1431
|
+
// arbitrary HTML, merged tables, or footnote-bearing tables would be less
|
|
1432
|
+
// correct than the prior clipped fallback.
|
|
836
1433
|
if (currentContent.length > 0) {
|
|
837
1434
|
finishPage();
|
|
838
1435
|
}
|
|
839
1436
|
currentContent.push(block.element.cloneNode(true));
|
|
840
|
-
|
|
1437
|
+
// Merge, never replace: finishPage() may have just seeded this page with notes deferred
|
|
1438
|
+
// from the previous one, and overwriting here dropped them from the document.
|
|
1439
|
+
currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
|
|
841
1440
|
finishPage();
|
|
842
1441
|
}
|
|
843
1442
|
}
|
|
@@ -847,6 +1446,28 @@ export class PaginationEngine {
|
|
|
847
1446
|
this.pendingFootnoteContinuation = nextPageContinuation;
|
|
848
1447
|
return pages;
|
|
849
1448
|
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Strip block addressing from a header/footer node cloned into a page box.
|
|
1451
|
+
*
|
|
1452
|
+
* A running story is authored ONCE and cloned onto every page, so the clones all carry the same
|
|
1453
|
+
* `data-anchor` — on this document, 42 page boxes claiming one footer paragraph. Left editable,
|
|
1454
|
+
* committing any one of them writes back through that single shared anchor, and the per-page
|
|
1455
|
+
* page-number substitution makes it worse: each clone shows a DIFFERENT number, so a commit
|
|
1456
|
+
* writes that page's number into the story as literal text and destroys the PAGE field.
|
|
1457
|
+
*
|
|
1458
|
+
* Page-box header/footer content is presentation. The docked editing bands
|
|
1459
|
+
* (`editor-headerfooter.ts`) are the addressable affordance, and they exist precisely because a
|
|
1460
|
+
* cloned node cannot be uniquely addressed.
|
|
1461
|
+
*/
|
|
1462
|
+
makeClonedStoryInert(root) {
|
|
1463
|
+
const nodes = [root, ...Array.from(root.querySelectorAll("*"))];
|
|
1464
|
+
for (const el of nodes) {
|
|
1465
|
+
el.removeAttribute("data-anchor");
|
|
1466
|
+
el.removeAttribute("data-committed-text");
|
|
1467
|
+
if (el.getAttribute("contenteditable") !== null)
|
|
1468
|
+
el.setAttribute("contenteditable", "false");
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
850
1471
|
/**
|
|
851
1472
|
* Creates a page container element.
|
|
852
1473
|
*/
|
|
@@ -883,6 +1504,9 @@ export class PaginationEngine {
|
|
|
883
1504
|
pageBox.style.contain = "layout paint";
|
|
884
1505
|
pageBox.dataset.pageNumber = String(pageNumber);
|
|
885
1506
|
pageBox.dataset.sectionIndex = String(sectionIndex);
|
|
1507
|
+
// Needed by substitutePageNumberFields: a section that restarts numbering counts from its own
|
|
1508
|
+
// first page, not from the document's.
|
|
1509
|
+
pageBox.dataset.pageInSection = String(pageInSection);
|
|
886
1510
|
// Get pre-computed effective heights for this page position (no re-measurement needed)
|
|
887
1511
|
const effectiveHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
|
|
888
1512
|
// Add header if available for this section/page
|
|
@@ -903,7 +1527,10 @@ export class PaginationEngine {
|
|
|
903
1527
|
headerDiv.style.paddingBottom = "4pt"; // Small gap before content area
|
|
904
1528
|
// Clone the header content (skip the wrapper div's data attributes)
|
|
905
1529
|
for (const child of Array.from(headerSource.childNodes)) {
|
|
906
|
-
|
|
1530
|
+
const clonedheaderDiv = child.cloneNode(true);
|
|
1531
|
+
if (clonedheaderDiv.nodeType === 1)
|
|
1532
|
+
this.makeClonedStoryInert(clonedheaderDiv);
|
|
1533
|
+
headerDiv.appendChild(clonedheaderDiv);
|
|
907
1534
|
}
|
|
908
1535
|
pageBox.appendChild(headerDiv);
|
|
909
1536
|
}
|
|
@@ -946,7 +1573,10 @@ export class PaginationEngine {
|
|
|
946
1573
|
footerDiv.style.paddingTop = "4pt"; // Small gap after content area
|
|
947
1574
|
// Clone the footer content (skip the wrapper div's data attributes)
|
|
948
1575
|
for (const child of Array.from(footerSource.childNodes)) {
|
|
949
|
-
|
|
1576
|
+
const clonedfooterDiv = child.cloneNode(true);
|
|
1577
|
+
if (clonedfooterDiv.nodeType === 1)
|
|
1578
|
+
this.makeClonedStoryInert(clonedfooterDiv);
|
|
1579
|
+
footerDiv.appendChild(clonedfooterDiv);
|
|
950
1580
|
}
|
|
951
1581
|
pageBox.appendChild(footerDiv);
|
|
952
1582
|
}
|
|
@@ -959,6 +1589,24 @@ export class PaginationEngine {
|
|
|
959
1589
|
}
|
|
960
1590
|
// Add to container
|
|
961
1591
|
this.containerElement.appendChild(pageBox);
|
|
1592
|
+
// Body and notes must occupy DISJOINT bands. The note block is absolutely positioned against
|
|
1593
|
+
// the page bottom and grows upward, while the content area spans the whole text height — so
|
|
1594
|
+
// nothing but the layout loop's arithmetic keeps them apart, and any disagreement between the
|
|
1595
|
+
// height the loop reserved and the height the notes actually render at draws body text and note
|
|
1596
|
+
// text on top of each other (observed on a 94-footnote document: ~134pt of superimposed,
|
|
1597
|
+
// illegible glyphs). Shrinking the content area to the space the notes actually left removes
|
|
1598
|
+
// the failure mode by construction — the worst case becomes a clean clip by the content area's
|
|
1599
|
+
// existing `overflow: hidden`, which is obvious and recoverable rather than silent corruption.
|
|
1600
|
+
//
|
|
1601
|
+
// This runs AFTER the append on purpose: `getBoundingClientRect()` on a detached node is all
|
|
1602
|
+
// zeroes, so measuring while building the page silently did nothing.
|
|
1603
|
+
const notesEl = pageBox.querySelector(`.${this.cssPrefix}footnotes`);
|
|
1604
|
+
if (notesEl) {
|
|
1605
|
+
const notesHeightPt = pxToPt(notesEl.getBoundingClientRect().height);
|
|
1606
|
+
if (notesHeightPt > 0) {
|
|
1607
|
+
contentArea.style.height = `${Math.max(0, contentAreaHeight - notesHeightPt)}pt`;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
962
1610
|
return {
|
|
963
1611
|
pageNumber,
|
|
964
1612
|
sectionIndex,
|
|
@@ -1014,7 +1662,13 @@ export function paginateHtml(html, container, options = {}) {
|
|
|
1014
1662
|
if (!pageContainer) {
|
|
1015
1663
|
throw new Error("Pagination container element not found");
|
|
1016
1664
|
}
|
|
1017
|
-
|
|
1665
|
+
// This convenience function is the read-only HTML viewer path. Keep the
|
|
1666
|
+
// lower-level engine conservative by default, while enabling paragraph
|
|
1667
|
+
// fragmentation here unless a caller deliberately opts out.
|
|
1668
|
+
const engine = new PaginationEngine(staging, pageContainer, {
|
|
1669
|
+
...options,
|
|
1670
|
+
fragmentParagraphs: options.fragmentParagraphs ?? true,
|
|
1671
|
+
});
|
|
1018
1672
|
return engine.paginate();
|
|
1019
1673
|
}
|
|
1020
1674
|
//# sourceMappingURL=pagination.js.map
|