From 91612eaf69d719ab27737390725ec465e7be0e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:11:29 +0100 Subject: [PATCH 1/6] fix: keep the Typst title bar with its code When a code window started near the bottom of a page, Typst could break the page right after the title bar, leaving it alone there while the code moved to the next page. The title bar block is now sticky, so it moves with the first lines of code. Long code still splits across pages. --- CHANGELOG.md | 1 + _extensions/code-window/code-window.lua | 1 + tests/fixtures/page-break-title.qmd | 33 ++++++++++++++++++++ tests/run.sh | 40 +++++++++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 tests/fixtures/page-break-title.qmd diff --git a/CHANGELOG.md b/CHANGELOG.md index d61aee8..d47b1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Bug Fixes - fix: Style inline code in a Typst theorem title like inline code anywhere else. (#78) +- fix: Keep the title bar on the same page as the first lines of its code in Typst, so it never sits alone at the bottom of a page. ## 1.6.0 (2026-10-05) diff --git a/_extensions/code-window/code-window.lua b/_extensions/code-window/code-window.lua index b1cf221..287fb33 100644 --- a/_extensions/code-window/code-window.lua +++ b/_extensions/code-window/code-window.lua @@ -538,6 +538,7 @@ local function build_typst_function_def(has_hotfixes) below: 0pt, radius: 0pt, stroke: (bottom: 1pt + border-colour), + sticky: true, title-bar, ) // Strip code block chrome so content fills flush against the window body. diff --git a/tests/fixtures/page-break-title.qmd b/tests/fixtures/page-break-title.qmd new file mode 100644 index 0000000..b13071e --- /dev/null +++ b/tests/fixtures/page-break-title.qmd @@ -0,0 +1,33 @@ +--- +title: "A title bar kept with its code across a page break" +filters: + - code-window +format: + typst: + keep-typ: true +--- + +Each window below starts 3em above the bottom of a page. +That leaves room for the title bar but not for the first line of code. + +```{=typst} +#set page(height: 10cm, margin: 1cm) +#v(8cm - 3em) +``` + +```{.python filename="plain.py"} +plain_first = 1 +plain_second = 2 +``` + +```{=typst} +#pagebreak() +#v(8cm - 3em) +``` + +```{.python filename="annotated.py"} +annotated_first = 1 # <1> +annotated_second = 2 +``` + +1. The first line. diff --git a/tests/run.sh b/tests/run.sh index 0bc9c75..0aab292 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -390,6 +390,46 @@ else "a box(fill: call inside the #theorem(title: [...]) line of theorem-title.typ" fi +# ============================================================================ +# A title bar never sits alone at the bottom of a page +# ============================================================================ + +# Each window in the fixture starts where the title bar fits on the page but +# the first line of code does not. The title bar has to move to the next page +# with its code. The plain window and the annotated window take different +# content paths in the wrapper, so both are checked. The test reads the page +# text of the PDF, so it needs pdftotext from poppler. +if ! command -v pdftotext >/dev/null 2>&1; then + printf 'FAIL page-break-title: pdftotext is not installed (brew install poppler)\n' + exit 1 +fi + +render page-break-title typst +page_count="$(pdfinfo "${work_dir}/page-break-title.pdf" | awk '/^Pages:/ { print $2 }')" + +# Answer the text of the page that shows a filename. +# $1 filename +page_with_filename() { + local page + local text + for page in $(seq 1 "${page_count}"); do + text="$(pdftotext -f "${page}" -l "${page}" "${work_dir}/page-break-title.pdf" -)" + if printf '%s\n' "${text}" | grep -qF "$1"; then + printf '%s\n' "${text}" + return + fi + done +} + +for window in plain annotated; do + if page_with_filename "${window}.py" | grep -qF "${window}_first = 1"; then + report pass "page-break-title: the ${window} title bar stays with its code" + else + report fail "page-break-title: the ${window} title bar stays with its code" \ + "${window}.py and ${window}_first = 1 on the same page of page-break-title.pdf" + fi +done + # ============================================================================ printf '\n%s passed, %s failed\n' "${passed}" "${failed}" From 7b9eefacbe1385e8376addbe5ede3644ff26c6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:14:54 +0100 Subject: [PATCH 2/6] test: read the PDF page text in one pass --- tests/run.sh | 47 ++++++++++++++++++----------------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 0aab292..f885d23 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -398,37 +398,26 @@ fi # the first line of code does not. The title bar has to move to the next page # with its code. The plain window and the annotated window take different # content paths in the wrapper, so both are checked. The test reads the page -# text of the PDF, so it needs pdftotext from poppler. -if ! command -v pdftotext >/dev/null 2>&1; then - printf 'FAIL page-break-title: pdftotext is not installed (brew install poppler)\n' - exit 1 -fi - -render page-break-title typst -page_count="$(pdfinfo "${work_dir}/page-break-title.pdf" | awk '/^Pages:/ { print $2 }')" - -# Answer the text of the page that shows a filename. -# $1 filename -page_with_filename() { - local page - local text - for page in $(seq 1 "${page_count}"); do - text="$(pdftotext -f "${page}" -l "${page}" "${work_dir}/page-break-title.pdf" -)" - if printf '%s\n' "${text}" | grep -qF "$1"; then - printf '%s\n' "${text}" - return +# text of the PDF, so it needs pdftotext from poppler. pdftotext ends each +# page with a form feed, so awk reads one page per record. +if command -v pdftotext >/dev/null 2>&1; then + render page-break-title typst + pdftotext "${work_dir}/page-break-title.pdf" "${work_dir}/page-break-title.txt" + + for window in plain annotated; do + if awk -v RS='\f' -v title="${window}.py" -v code="${window}_first = 1" \ + 'index($0, title) && index($0, code) { found = 1 } END { exit !found }' \ + "${work_dir}/page-break-title.txt"; then + report pass "page-break-title: the ${window} title bar stays with its code" + else + report fail "page-break-title: the ${window} title bar stays with its code" \ + "${window}.py and ${window}_first = 1 on the same page of page-break-title.pdf" fi done -} - -for window in plain annotated; do - if page_with_filename "${window}.py" | grep -qF "${window}_first = 1"; then - report pass "page-break-title: the ${window} title bar stays with its code" - else - report fail "page-break-title: the ${window} title bar stays with its code" \ - "${window}.py and ${window}_first = 1 on the same page of page-break-title.pdf" - fi -done +else + report fail "page-break-title: the page text can be read" \ + "pdftotext on PATH (brew install poppler)" +fi # ============================================================================ From bee6bc334ce2ddd0382b8cb3c60f1098c3e55852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:19:02 +0100 Subject: [PATCH 3/6] test: prove the page-break fixture forces the break --- tests/run.sh | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index f885d23..673f564 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -54,6 +54,15 @@ count_typst_windows() { grep -c '^#code-window(filename' "$1" || true } +# Succeed when one page of page-break-title.txt holds both strings. pdftotext +# ends each page with a form feed, so awk reads one page per record. +# $1 first string. $2 second string. +same_page() { + awk -v RS='\f' -v first="$1" -v second="$2" \ + 'index($0, first) && index($0, second) { found = 1 } END { exit !found }' \ + "${work_dir}/page-break-title.txt" +} + # Answer the classes of every highlighted block in a rendered HTML file. The # injected script names its marker classes in a selector, never in a tag, so # reading the tags alone keeps the script out of the answer. @@ -398,22 +407,35 @@ fi # the first line of code does not. The title bar has to move to the next page # with its code. The plain window and the annotated window take different # content paths in the wrapper, so both are checked. The test reads the page -# text of the PDF, so it needs pdftotext from poppler. pdftotext ends each -# page with a form feed, so awk reads one page per record. +# text of the PDF, so it needs pdftotext from poppler. if command -v pdftotext >/dev/null 2>&1; then render page-break-title typst pdftotext "${work_dir}/page-break-title.pdf" "${work_dir}/page-break-title.txt" for window in plain annotated; do - if awk -v RS='\f' -v title="${window}.py" -v code="${window}_first = 1" \ - 'index($0, title) && index($0, code) { found = 1 } END { exit !found }' \ - "${work_dir}/page-break-title.txt"; then + if same_page "${window}.py" "${window}_first = 1"; then report pass "page-break-title: the ${window} title bar stays with its code" else report fail "page-break-title: the ${window} title bar stays with its code" \ "${window}.py and ${window}_first = 1 on the same page of page-break-title.pdf" fi done + + # Without the sticky title bar, the same fixture has to leave the title bar + # alone on its page. This proves that the fixture still forces the break, + # so the checks above test the fix and not a layout that fits anyway. + lua_file="${work_dir}/_extensions/code-window/code-window.lua" + sed -i.aside '/sticky: true,/d' "${lua_file}" + render page-break-title typst + mv "${lua_file}.aside" "${lua_file}" + pdftotext "${work_dir}/page-break-title.pdf" "${work_dir}/page-break-title.txt" + + if same_page "plain.py" "plain_first = 1"; then + report fail "page-break-title: the fixture orphans a title bar without the fix" \ + "plain.py and plain_first = 1 on different pages without sticky: true" + else + report pass "page-break-title: the fixture orphans a title bar without the fix" + fi else report fail "page-break-title: the page text can be read" \ "pdftotext on PATH (brew install poppler)" From 957960d15efc08450fa18a6b6e8bc41e344ce8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:22:48 +0100 Subject: [PATCH 4/6] test: check both page-break windows without the fix --- tests/run.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 673f564..a37d69c 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -9,6 +9,8 @@ set -euo pipefail # fixtures rather than linked. docs/_scripts/sync-extension.sh solves the same # problem for the documentation site. # +# Requires: quarto, and pdftotext from poppler for the page-break test. +# # Usage: tests/run.sh tests_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -430,12 +432,14 @@ if command -v pdftotext >/dev/null 2>&1; then mv "${lua_file}.aside" "${lua_file}" pdftotext "${work_dir}/page-break-title.pdf" "${work_dir}/page-break-title.txt" - if same_page "plain.py" "plain_first = 1"; then - report fail "page-break-title: the fixture orphans a title bar without the fix" \ - "plain.py and plain_first = 1 on different pages without sticky: true" - else - report pass "page-break-title: the fixture orphans a title bar without the fix" - fi + for window in plain annotated; do + if same_page "${window}.py" "${window}_first = 1"; then + report fail "page-break-title: the fixture orphans the ${window} title bar without the fix" \ + "${window}.py and ${window}_first = 1 on different pages without sticky: true" + else + report pass "page-break-title: the fixture orphans the ${window} title bar without the fix" + fi + done else report fail "page-break-title: the page text can be read" \ "pdftotext on PATH (brew install poppler)" From 2683285010060712eae00104796a4183186f11b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:27:17 +0100 Subject: [PATCH 5/6] test: check a tall window starts on its own page --- CHANGELOG.md | 2 +- tests/fixtures/page-break-title.qmd | 73 ++++++++++++++++++++++++++++- tests/run.sh | 10 ++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d47b1de..4103e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Bug Fixes - fix: Style inline code in a Typst theorem title like inline code anywhere else. (#78) -- fix: Keep the title bar on the same page as the first lines of its code in Typst, so it never sits alone at the bottom of a page. +- fix: Keep the title bar on the same page as the first lines of its code in Typst, so it never sits alone at the bottom of a page. The Typst bundled with Quarto 1.10.19 still breaks a window taller than a page from the current page, which 1.2.1 could not rely on. ## 1.6.0 (2026-10-05) diff --git a/tests/fixtures/page-break-title.qmd b/tests/fixtures/page-break-title.qmd index b13071e..085b758 100644 --- a/tests/fixtures/page-break-title.qmd +++ b/tests/fixtures/page-break-title.qmd @@ -7,7 +7,7 @@ format: keep-typ: true --- -Each window below starts 3em above the bottom of a page. +The first two windows start 3em above the bottom of a page. That leaves room for the title bar but not for the first line of code. ```{=typst} @@ -31,3 +31,74 @@ annotated_second = 2 ``` 1. The first line. + +```{=typst} +#pagebreak() +``` + +Text above the tall window. + +## Tall heading + +```{.python filename="tall.py"} +tall_0 = 0 +tall_1 = 1 +tall_2 = 2 +tall_3 = 3 +tall_4 = 4 +tall_5 = 5 +tall_6 = 6 +tall_7 = 7 +tall_8 = 8 +tall_9 = 9 +tall_10 = 10 +tall_11 = 11 +tall_12 = 12 +tall_13 = 13 +tall_14 = 14 +tall_15 = 15 +tall_16 = 16 +tall_17 = 17 +tall_18 = 18 +tall_19 = 19 +tall_20 = 20 +tall_21 = 21 +tall_22 = 22 +tall_23 = 23 +tall_24 = 24 +tall_25 = 25 +tall_26 = 26 +tall_27 = 27 +tall_28 = 28 +tall_29 = 29 +tall_30 = 30 +tall_31 = 31 +tall_32 = 32 +tall_33 = 33 +tall_34 = 34 +tall_35 = 35 +tall_36 = 36 +tall_37 = 37 +tall_38 = 38 +tall_39 = 39 +tall_40 = 40 +tall_41 = 41 +tall_42 = 42 +tall_43 = 43 +tall_44 = 44 +tall_45 = 45 +tall_46 = 46 +tall_47 = 47 +tall_48 = 48 +tall_49 = 49 +tall_50 = 50 +tall_51 = 51 +tall_52 = 52 +tall_53 = 53 +tall_54 = 54 +tall_55 = 55 +tall_56 = 56 +tall_57 = 57 +tall_58 = 58 +tall_59 = 59 +``` diff --git a/tests/run.sh b/tests/run.sh index a37d69c..05e1ebe 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -423,6 +423,16 @@ if command -v pdftotext >/dev/null 2>&1; then fi done + # A window taller than a page has to start on the page where it is placed + # and break across pages. The sticky title bar must not push it whole to + # the next page and leave a near-empty page behind. + if same_page "Text above the tall window." "tall_0 = 0"; then + report pass "page-break-title: a window taller than a page starts on the current page" + else + report fail "page-break-title: a window taller than a page starts on the current page" \ + "the text above tall.py and tall_0 = 0 on the same page of page-break-title.pdf" + fi + # Without the sticky title bar, the same fixture has to leave the title bar # alone on its page. This proves that the fixture still forces the break, # so the checks above test the fix and not a layout that fits anyway. From 0ce29dc2ee16f04ee44951d07b806b31fe14f6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:28:37 +0100 Subject: [PATCH 6/6] docs: add the pull request to the changelog entry [skip ci] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4103e91..cd1fa7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Bug Fixes - fix: Style inline code in a Typst theorem title like inline code anywhere else. (#78) -- fix: Keep the title bar on the same page as the first lines of its code in Typst, so it never sits alone at the bottom of a page. The Typst bundled with Quarto 1.10.19 still breaks a window taller than a page from the current page, which 1.2.1 could not rely on. +- fix: Keep the title bar on the same page as the first lines of its code in Typst, so it never sits alone at the bottom of a page. The Typst bundled with Quarto 1.10.19 still breaks a window taller than a page from the current page, which 1.2.1 could not rely on. (#80) ## 1.6.0 (2026-10-05)