postext 0.3.34 → 0.3.35

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.
@@ -364,9 +364,11 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
364
364
  * decides what becomes of the rows left over — a rest float to continue
365
365
  * on the next page (`'split'`), nothing (`'clip'`) — or, for `'hide'`,
366
366
  * that the table is dropped. Returns null for a figure or a table with
367
- * nothing to cut, which are force-placed like before.
367
+ * nothing to cut, which are force-placed like before. In the `'strict'`
368
+ * (current-page) mode nothing is forced: `'none'` when not even the
369
+ * smallest slice fits the slot.
368
370
  */
369
- const splitTableFloat = (f, width, position, targetCols, contentArea, avail) => {
371
+ const splitTableFloat = (f, width, position, targetCols, contentArea, avail, mode) => {
370
372
  const rowCount = tableRowCount(f.resourceId);
371
373
  if (rowCount === 0)
372
374
  return null;
@@ -390,11 +392,15 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
390
392
  // `avail` is the grid multiple below it, less the gap.
391
393
  const hMax = Math.floor((avail + 0.01) / baselineGrid) * baselineGrid - floatGapPx;
392
394
  let end = planTableSlice(metrics, startRow, hMax - overhead);
393
- // Nothing fits: carry the smallest slice anyway (it overflows, as a
394
- // dominating figure would) rather than stall the queue.
395
395
  const floor = firstBody + 1;
396
- if (end < floor)
396
+ if (end < floor) {
397
+ // Nothing fits. A fresh page carries the smallest slice anyway (it
398
+ // overflows, as a dominating figure would) rather than stall the
399
+ // queue; a slot of the current page is simply not this table's.
400
+ if (mode === 'strict')
401
+ return 'none';
397
402
  end = floor;
403
+ }
398
404
  // A last page holding a row or two under a repeated header reads as a
399
405
  // stranded tail: give the closing slice at least `MIN_TAIL_ROWS` rows by
400
406
  // handing some back from this one (at a breakable edge, not under a
@@ -429,6 +435,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
429
435
  while (end > floor && !(metrics.breakableAfter[end - 1] ?? true));
430
436
  slice = sliceFor(end);
431
437
  }
438
+ if (mode === 'strict' && !fits(slice))
439
+ return 'none';
432
440
  const rest = slice.continues ? { ...f, startRow: end } : undefined;
433
441
  return rest ? { slice, rest } : { slice };
434
442
  };
@@ -483,6 +491,8 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
483
491
  const { width, xLeft } = probe;
484
492
  let { slice, measure, need, y } = probe;
485
493
  let rest;
494
+ /** The float was cut to this slot (a table slice). */
495
+ let cut = false;
486
496
  if (mode === 'fresh') {
487
497
  let minAvail = Infinity;
488
498
  let anyReserved = false;
@@ -496,12 +506,13 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
496
506
  return 'defer';
497
507
  if (need > minAvail + 0.01) {
498
508
  // Dominating the band: a table is cut to it.
499
- const split = splitTableFloat(f, width, position, targetCols, page.contentArea, minAvail);
509
+ const split = splitTableFloat(f, width, position, targetCols, page.contentArea, minAvail, 'fresh');
500
510
  if (split === 'skip')
501
511
  return 'skip';
502
- if (split) {
512
+ if (split && split !== 'none') {
503
513
  slice = split.slice;
504
514
  rest = split.rest;
515
+ cut = true;
505
516
  const m = measureFloat(f.resourceId, width, slice);
506
517
  if (!m)
507
518
  return 'skip';
@@ -518,10 +529,44 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
518
529
  return 'defer';
519
530
  continue;
520
531
  }
521
- if (!fitsStrict(need, c, columnHasFloatBand(page, c), minTextPx))
532
+ const hasBand = columnHasFloatBand(page, c);
533
+ if (fitsStrict(need, c, hasBand, minTextPx))
534
+ continue;
535
+ // The head of an empty column: a splittable table is cut to the
536
+ // column and continues in the next slot — the compositor sets a
537
+ // long table beside the text that cites it, not pages later. A
538
+ // table that clips or hides when too tall keeps to fresh pages.
539
+ if (position !== 'top' || pageSpan || c.blocks.length > 0)
540
+ return 'defer';
541
+ if (resolved.tableStyle.overflow !== 'split')
542
+ return 'defer';
543
+ const split = splitTableFloat(f, width, position, targetCols, page.contentArea, c.availableHeight - (hasBand ? minTextPx : 0), 'strict');
544
+ if (!split || split === 'none' || split === 'skip')
545
+ return 'defer';
546
+ slice = split.slice;
547
+ rest = split.rest;
548
+ cut = true;
549
+ const m = measureFloat(f.resourceId, width, slice);
550
+ if (!m)
551
+ return 'skip';
552
+ measure = m;
553
+ ({ need, y } = measureFloatBand(position, measure, targetCols, page.contentArea, baselineGrid, floatGapPx, (cc) => trueBottom(cc, uncappedBottoms)));
554
+ if (!fitsStrict(need, c, hasBand, minTextPx))
522
555
  return 'defer';
523
556
  }
524
557
  }
558
+ // A slice cut to the head of a column takes the column whole when the
559
+ // rows leave less than the text minimum under it: a line or two of
560
+ // body text stranded under a table reads worse than an empty foot.
561
+ if (cut && position === 'top') {
562
+ const minAvail = Math.min(...targetCols.map((c) => c.availableHeight));
563
+ if (minAvail - need < minTextPx)
564
+ need = Math.max(need, minAvail);
565
+ }
566
+ // The rest goes on after this slice in reading order, never before.
567
+ if (rest) {
568
+ rest = { ...rest, notBefore: { pageIndex: page.index, columnIndex: targetCols[targetCols.length - 1].index } };
569
+ }
525
570
  const built = buildFloatBlock(f.resourceId, xLeft, width, slice);
526
571
  if (!built)
527
572
  return 'skip';
@@ -563,35 +608,61 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
563
608
  return rest ? { rest } : 'placed';
564
609
  };
565
610
  /** Apply a slot outcome to the queue at `i`: drop a placed float, keep a
566
- * deferred one, swap in the rest of a split table. Returns the index to
567
- * continue from. */
611
+ * deferred one, swap in the rest of a split table (offered the next slot
612
+ * right away, so a table cut to one column goes on in the column beside
613
+ * it). Returns the index to continue from. */
568
614
  const settle = (i, r) => {
569
615
  if (r === 'defer')
570
616
  return i + 1;
571
617
  if (typeof r === 'object') {
572
618
  pendingFloats[i] = r.rest;
573
- return i + 1;
619
+ return i;
574
620
  }
575
621
  pendingFloats.splice(i, 1);
576
622
  return i;
577
623
  };
624
+ /** Whether the pending float at `i` must wait: an earlier float of the
625
+ * same numbering sequence (resource type) is still pending. Figures and
626
+ * tables are numbered in first-reference order, and the reader must
627
+ * meet them in that order too — table 3 never lands after table 4, even
628
+ * when 4 would fit a slot 3 does not. Sequences do not hold each other
629
+ * up: a waiting table lets a later figure through. */
630
+ const heldBack = (i) => {
631
+ const typeId = resourceById.get(pendingFloats[i].resourceId)?.typeId;
632
+ for (let j = 0; j < i; j++) {
633
+ if (resourceById.get(pendingFloats[j].resourceId)?.typeId === typeId)
634
+ return true;
635
+ }
636
+ return false;
637
+ };
578
638
  const positionsFor = (f) => f.position === 'auto' ? ['top', 'bottom'] : [f.position];
579
639
  /** Reserve top/bottom bands on a freshly opened page and position as many
580
640
  * pending floats as fit, shrinking the affected columns so body text flows
581
641
  * around them. Full-width (page-span) floats reserve the outermost bands
582
642
  * first, so a later single-column float nests inside the remaining column
583
- * space rather than overlapping a full-width band. A float that does not
584
- * fit never holds up the ones behind it: each takes the first slot it
585
- * fits (numbering follows first-reference order regardless). */
643
+ * space rather than overlapping a full-width band; the passes repeat
644
+ * while they place something, so a page-span float held back behind a
645
+ * column float of its sequence still gets the page's foot once that one
646
+ * is set. A float that does not fit holds up the ones behind it in its
647
+ * numbering sequence (see `heldBack`), never the other sequence. */
586
648
  const flushFloatsIntoPage = (page) => {
587
649
  if (pendingFloats.length === 0)
588
650
  return;
589
651
  const textCols = page.columns.filter((c) => c.kind !== 'span');
590
652
  if (textCols.length === 0)
591
653
  return;
592
- const leastReserved = () => {
593
- let best = textCols[0];
654
+ /** The least reserved text column a float may take (the rest of a
655
+ * split table: only columns after its previous slice on this page). */
656
+ const leastReserved = (f) => {
657
+ const after = f.notBefore && f.notBefore.pageIndex === page.index ? f.notBefore.columnIndex : -1;
658
+ let best;
594
659
  for (const c of textCols) {
660
+ if (c.index <= after)
661
+ continue;
662
+ if (!best) {
663
+ best = c;
664
+ continue;
665
+ }
595
666
  const rb = reservedOf(best);
596
667
  const rc = reservedOf(c);
597
668
  if (rc.top + rc.bottom < rb.top + rb.bottom)
@@ -599,24 +670,36 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
599
670
  }
600
671
  return best;
601
672
  };
602
- for (const pageSpanPass of [true, false]) {
603
- let i = 0;
604
- while (i < pendingFloats.length) {
605
- const f = pendingFloats[i];
606
- const isPageSpan = f.span === 'page' && textCols.length > 1;
607
- if (isPageSpan !== pageSpanPass) {
608
- i++;
609
- continue;
610
- }
611
- let r = 'defer';
612
- for (const pos of positionsFor(f)) {
613
- const cols = isPageSpan ? textCols : [leastReserved()];
614
- r = placeFloatInColumns(page, f, cols, pos, isPageSpan, 'fresh');
615
- if (r !== 'defer')
616
- break;
673
+ for (let progress = true; progress;) {
674
+ const before = floatsPlaced;
675
+ for (const pageSpanPass of [true, false]) {
676
+ let i = 0;
677
+ while (i < pendingFloats.length) {
678
+ const f = pendingFloats[i];
679
+ const isPageSpan = f.span === 'page' && textCols.length > 1;
680
+ if (isPageSpan !== pageSpanPass || heldBack(i)) {
681
+ i++;
682
+ continue;
683
+ }
684
+ // A page-span rest never shares the page of its previous slice.
685
+ if (isPageSpan && f.notBefore?.pageIndex === page.index) {
686
+ i++;
687
+ continue;
688
+ }
689
+ let r = 'defer';
690
+ for (const pos of positionsFor(f)) {
691
+ const col = isPageSpan ? undefined : leastReserved(f);
692
+ if (!isPageSpan && !col)
693
+ break;
694
+ const cols = isPageSpan ? textCols : [col];
695
+ r = placeFloatInColumns(page, f, cols, pos, isPageSpan, 'fresh');
696
+ if (r !== 'defer')
697
+ break;
698
+ }
699
+ i = settle(i, r);
617
700
  }
618
- i = settle(i, r);
619
701
  }
702
+ progress = floatsPlaced > before;
620
703
  }
621
704
  };
622
705
  /** The keep-together box content block `idx` opens, when it is one that
@@ -686,9 +769,20 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
686
769
  const page = doc.pages[cursor.pageIndex];
687
770
  const box = nextBlockIdx !== undefined ? keepTogetherBoxAt(nextBlockIdx) : null;
688
771
  for (let i = 0; i < pendingFloats.length;) {
772
+ if (heldBack(i)) {
773
+ i++;
774
+ continue;
775
+ }
689
776
  const f = pendingFloats[i];
690
777
  let r = 'defer';
691
778
  let slots = enumerateCurrentPageSlots(page, cursor.columnIndex, f, capKindOf);
779
+ // The rest of a table cut on this page only takes the slots after
780
+ // its previous slice in reading order (never the foot of the column
781
+ // before it; a page-span rest waits for the next page).
782
+ if (f.notBefore && f.notBefore.pageIndex === page.index) {
783
+ const after = f.notBefore.columnIndex;
784
+ slots = slots.filter((s) => !s.pageSpan && s.cols[0].index > after);
785
+ }
692
786
  // A page-span box comes next: the head of an empty column keeps the
693
787
  // band cuttable under the float (the box then sits below both the
694
788
  // text and the figure), where the referencing column's foot would
@@ -1212,7 +1306,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1212
1306
  for (let i = 0; i < pendingFloats.length;) {
1213
1307
  const f = pendingFloats[i];
1214
1308
  const cols = bandColumns(page, currentBand(page, cursor));
1215
- if (f.span !== 'page' || cols.length < 2 || !((capActiveHere && !placedAny) || levelForBox(cols))) {
1309
+ if (f.span !== 'page' || cols.length < 2 || heldBack(i) || !((capActiveHere && !placedAny) || levelForBox(cols))) {
1216
1310
  i++;
1217
1311
  continue;
1218
1312
  }
@@ -1309,7 +1403,7 @@ export function buildDocumentPass(content, config, cache, options, hints = {}) {
1309
1403
  // box moving on and the band ending level like a closing one.
1310
1404
  // Only a page-span figure is planned for here; column figures keep
1311
1405
  // the ordinary slots (their level is the band cap's own business).
1312
- const first = pendingFloats.find((f) => f.span === 'page');
1406
+ const first = pendingFloats.find((f, i) => f.span === 'page' && !heldBack(i));
1313
1407
  if (first && !capActive && cap === undefined && bandStart && registeredBand
1314
1408
  && registeredBand.pageIndex === page.index && registeredBand.band === bandBefore) {
1315
1409
  const cols = bandColumns(page, bandBefore).filter((c) => c.bbox.height > 0.5);