From b79feff4cd01129b29ec5f1dbae6d2d16c9cdf39 Mon Sep 17 00:00:00 2001 From: Kyle Monette Date: Tue, 1 Sep 2026 16:12:34 -0400 Subject: [PATCH 1/2] JavaScript: Search both directions for a legal printout page break --- js/src/pretext-printouts.js | 66 +++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/js/src/pretext-printouts.js b/js/src/pretext-printouts.js index 2ef26a585..da2787915 100644 --- a/js/src/pretext-printouts.js +++ b/js/src/pretext-printouts.js @@ -795,6 +795,19 @@ function isPageFurnitureEl(el) { isPageTailEl(el); } +// Whether a new page could legally start at `contentChildren[index]`: not +// with blank writing space (see isVisibleWorkspaceRow()), and not splitting a +// question from its own solutions/workspace. Shared by both directions +// addSpilloverPages() searches, so they agree on what "legal" means. +function isLegalSplit(contentChildren, index) { + const row = contentChildren[index]; + const prev = contentChildren[index - 1]; + const opensWithWorkspace = isVisibleWorkspaceRow(row); + const splitsGroup = !!(row.dataset.blockGroup && + row.dataset.blockGroup === prev.dataset.blockGroup); + return !opensWithWorkspace && !splitsGroup; +} + function addSpilloverPages(margins) { const printout = getPrintout(); if (!printout) return; @@ -843,25 +856,44 @@ function addSpilloverPages(margins) { // The very first row alone is already too tall to fit on any page — // nothing we can do about that specific row. But if there are other // rows after it, they shouldn't be trapped here too: move everything - // after the oversized first row onto a fresh page. + // after the oversized first row onto a fresh page. Forced, so taken + // unconditionally rather than run through isLegalSplit() below. if (contentChildren.length <= 1) continue; // truly nothing else to move overflowStartIndex = 1; - } - // This is the runtime counterpart of the rules findPageBreaks() applies - // when it plans pages from scratch, and it has to enforce them too: a - // reveal goes through here (applySolutionVisibility() repaginates with - // fullRecompute false), so without this a revealed solution can push its - // own workspace onto the next page and open that page with a slab of - // blank writing space, or tear a question away from its solutions. - // Both are fixed the same way -- retreat to an earlier, legal split. - while (overflowStartIndex > 1) { - const row = contentChildren[overflowStartIndex]; - const prev = contentChildren[overflowStartIndex - 1]; - const opensWithWorkspace = isVisibleWorkspaceRow(row); - const splitsGroup = !!(row.dataset.blockGroup && - row.dataset.blockGroup === prev.dataset.blockGroup); - if (!opensWithWorkspace && !splitsGroup) break; - overflowStartIndex--; + } else { + // This is the runtime counterpart of the rules findPageBreaks() applies + // when it plans pages from scratch, and it has to enforce them too: a + // reveal goes through here (applySolutionVisibility() repaginates with + // fullRecompute false), so without this a revealed solution can push + // its own workspace onto the next page and open that page with a slab + // of blank writing space, or tear a question away from its solutions. + // + // Retreat to the nearest legal split. Bounded at 0, not 1: a split + // right at index 1 is exactly as capable of being illegal as any + // other candidate, and has to be checked like every other one. + let candidate = overflowStartIndex; + while (candidate > 0 && !isLegalSplit(contentChildren, candidate)) { + candidate--; + } + if (candidate > 0) { + overflowStartIndex = candidate; + } else { + // No legal split before the overflow point -- likely an oversized + // group whose own solutions/workspace lie ahead, which retreating + // can never get past. Search forward instead for the next legal + // boundary, so at least what follows the group lands on a fresh + // page, and the group gets a page of its own to be squeezed onto. + candidate = overflowStartIndex + 1; + while (candidate < contentChildren.length && !isLegalSplit(contentChildren, candidate)) { + candidate++; + } + if (candidate >= contentChildren.length) { + // No legal split in either direction: one group, too tall for a + // page by itself, like the "row taller than a page" case above. + continue; + } + overflowStartIndex = candidate; + } } const overflowElems = contentChildren.slice(overflowStartIndex); From b1b432cc46ea9f985b005d7b387c4e299babdc4c Mon Sep 17 00:00:00 2001 From: Kyle Monette Date: Tue, 1 Sep 2026 16:14:43 -0400 Subject: [PATCH 2/2] CSS: printout pages size to content in print, fixing blank pages in Safari --- css/targets/print-worksheet/print-worksheet.scss | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/css/targets/print-worksheet/print-worksheet.scss b/css/targets/print-worksheet/print-worksheet.scss index f319cca3c..72eac625e 100644 --- a/css/targets/print-worksheet/print-worksheet.scss +++ b/css/targets/print-worksheet/print-worksheet.scss @@ -606,15 +606,25 @@ form.papersize-select { .onepage { margin-top: 0; width: 100%; - height: var(--ws-content-height); + // Not a fixed height: `--ws-content-height` and the printable area + // Safari derives from `@page`'s margin can round to disagree by a + // sub-pixel, which Safari (unlike Chrome) paginates as a blank page + // after every page. Sizing to content -- already paginated to fit -- + // sidesteps the mismatch instead of racing its rounding. + height: auto; overflow: hidden; + // `break-after`/`break-inside` are the modern Fragmentation-spec + // properties; `page-break-*` are the aliases older engines need. page-break-after: always; page-break-inside: avoid; + break-after: page; + break-inside: avoid; } // Avoid a blank page at the end if the last page fits on the previous page .onepage:last-of-type { page-break-after: avoid; + break-after: avoid; } .first-page-header, .running-header {