From 23b0aeadd0dc1853e9dcdcedc17788444f0b6d26 Mon Sep 17 00:00:00 2001 From: Rene Pietzsch Date: Tue, 15 Sep 2026 11:05:55 +0200 Subject: [PATCH 01/15] add print-on-demand edition of the PDF `task pdf:print` builds the book block for a printed book (BoD, A4, black and white) next to the screen PDF, as planned in tasks/spec.md. - mirrored page furniture, right-hand part starts, title page and imprint with an author list from `task pdf:authors` - links printed as page references and footnotes, images at 300 ppi without transparency, a black-and-white palette - tools/pdf/print.yml shortens the operator reference and the release notes to overview tables; reference categories print as one table - long code lines wrap inside their frame - both editions: tall figures scale to the page, image figures carry no Typst number - fix the transformer reference table, which ended at the Excel map row: the generator turns a fenced block in a description into a code span --- Taskfile.yml | 37 ++ .../reference/transformer/Replace/excelMap.md | 2 +- docs/build/reference/transformer/index.md | 3 +- poetry.lock | 127 +++- pyproject.toml | 1 + tasks/backlog.md | 428 ++++++++------ tasks/spec.md | 558 +++++++++++------- tests/test_build_pdf.py | 13 +- tests/test_build_pdf_images.py | 83 +++ tests/test_build_pdf_print.py | 248 ++++++++ tests/test_pdf_authors.py | 45 ++ tests/test_update_di_reference.py | 29 +- tools/__init__.py | 2 + tools/build_pdf.py | 555 +++++++++++++++-- tools/pdf/authors.yml | 49 ++ tools/pdf/filter.lua | 3 +- tools/pdf/print.yml | 30 + tools/pdf/style.typ | 312 ++++++++-- tools/pdf_authors.py | 104 ++++ tools/update_di_reference.py | 14 +- 20 files changed, 2152 insertions(+), 491 deletions(-) create mode 100644 tests/test_build_pdf_images.py create mode 100644 tests/test_build_pdf_print.py create mode 100644 tests/test_pdf_authors.py create mode 100644 tools/pdf/authors.yml create mode 100644 tools/pdf/print.yml create mode 100644 tools/pdf_authors.py diff --git a/Taskfile.yml b/Taskfile.yml index 837b9c496..93956aecf 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -200,6 +200,43 @@ tasks: cmds: - poetry run dec-tool build-pdf + pdf:print: + desc: Build the print edition of the PDF - the book block for print on demand + summary: | + Runs `task build`, then `dec-tool build-pdf --edition print`: the same + merge, pandoc run and fonts as `task pdf`, typeset as the book block of + a printed book (BoD, A4, black and white). tasks/spec.md describes it: + mirrored page furniture, right-hand part starts, title page and + imprint, links printed as page references and footnotes, and images + normalized to 300 ppi without transparency. + + tools/pdf/print.yml configures the edition: the publisher, and how each + navigation section is printed (full, list or omit). + tools/pdf/authors.yml is the imprint's author list; refresh it with + `task pdf:authors`. + + Writes dist/documentation-eccenca-com--print.pdf; the merged HTML, + the Typst source and the normalized images stay in dist/pdf/print/. The + screen PDF of `task pdf` is not affected. + deps: + - task: build + env: + BUILD_VERSION: '{{.CURRENT_VERSION}}' + cmds: + - poetry run dec-tool build-pdf --edition print + + pdf:authors: + desc: Refresh the author list of the print edition's imprint + summary: | + Writes tools/pdf/authors.yml from the GitHub contributors of this + repository: most commits first, without anonymous contributions, bot + accounts and software agents. Commit the result. Uses GITHUB_TOKEN or + GH_TOKEN when set; unauthenticated requests are rate limited. + deps: + - task: install + cmds: + - poetry run dec-tool pdf-authors + update:icons: desc: update the used eccenca icons from carbon cmds: diff --git a/docs/build/reference/transformer/Replace/excelMap.md b/docs/build/reference/transformer/Replace/excelMap.md index ae0489f48..a04d8873c 100644 --- a/docs/build/reference/transformer/Replace/excelMap.md +++ b/docs/build/reference/transformer/Replace/excelMap.md @@ -1,6 +1,6 @@ --- title: "Excel map" -description: "Replaces values based on a map of values read from a file in Open XML format (XLSX). The XLSX file may contain several sheets of the form: ```mapFrom,mapTo ,``` and more. An empty string can be created in Excel and alternatives by inserting `=''` in the input line of a cell. If there are multiple values for a single key, all values will be returned for the given key. Note that the mapping table will be cached in memory. If the Excel file is updated (even while transforming), the map will be reloaded within seconds." +description: "Replaces values based on a map of values read from a file in Open XML format (XLSX). The XLSX file may contain several sheets of the form: `mapFrom,mapTo ,` and more. An empty string can be created in Excel and alternatives by inserting `=''` in the input line of a cell. If there are multiple values for a single key, all values will be returned for the given key. Note that the mapping table will be cached in memory. If the Excel file is updated (even while transforming), the map will be reloaded within seconds." icon: octicons/cross-reference-24 tags: - TransformOperator diff --git a/docs/build/reference/transformer/index.md b/docs/build/reference/transformer/index.md index 74762b5d3..a9e31201b 100644 --- a/docs/build/reference/transformer/index.md +++ b/docs/build/reference/transformer/index.md @@ -94,8 +94,7 @@ Transform operators transform a one or more sequences of string values to a sequ | [Excel Ln](Excel/Excel_LN.md) | Excel | Excel LN(number): Returns the natural logarithm based on the constant e of the given number. | | [Excel Log](Excel/Excel_LOG.md) | Excel | Excel LOG(number; base): Returns the logarithm of the given number to the specified base. Base is the base for the logarithm calculation. | | [Excel Log10](Excel/Excel_LOG10.md) | Excel | Excel LOG10(number): Returns the base-10 logarithm of the given number. | - - | [Excel map](Replace/excelMap.md) | Replace | Replaces values based on a map of values read from a file in Open XML format (XLSX). The XLSX file may contain several sheets of the form: ```mapFrom,mapTo ,``` and more. An empty string can be created in Excel and alternatives by inserting `=""` in the input line of a cell. If there are multiple values for a single key, all values will be returned for the given key. Note that the mapping table will be cached in memory. If the Excel file is updated (even while transforming), the map will be reloaded within seconds. | + | [Excel map](Replace/excelMap.md) | Replace | Replaces values based on a map of values read from a file in Open XML format (XLSX). The XLSX file may contain several sheets of the form: `mapFrom,mapTo ,` and more. An empty string can be created in Excel and alternatives by inserting `=""` in the input line of a cell. If there are multiple values for a single key, all values will be returned for the given key. Note that the mapping table will be cached in memory. If the Excel file is updated (even while transforming), the map will be reloaded within seconds. | | [Excel Max](Excel/Excel_MAX.md) | Excel | Excel MAX(number_1; number_2; ... number_30): Returns the maximum value in a list of arguments. Number_1; number_2; ... number_30 are numerical values or ranges. | | [Excel Maxa](Excel/Excel_MAXA.md) | Excel | Excel MAXA(value_1; value_2; ... value_30): Returns the maximum value in a list of arguments. Unlike MAX, text can be entered. The value of the text is 0. Value_1; value_2; ... value_30 are values or ranges. | | [Excel Median](Excel/Excel_MEDIAN.md) | Excel | Excel MEDIAN(number_1; number_2; ... number_30): Returns the median of a set of numbers. Number_1; number_2; ... number_30 are values or ranges, which represent a sample. Each number can also be replaced by a reference. | diff --git a/poetry.lock b/poetry.lock index 0ce9d9fef..1df274afd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1194,6 +1194,131 @@ hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] +[[package]] +name = "pillow" +version = "11.3.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, + {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, + {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, + {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, + {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, + {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, + {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, + {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, + {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, + {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, + {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, + {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, + {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, + {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, + {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, + {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, + {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, + {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, + {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, + {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, + {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, + {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, + {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, + {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, + {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, + {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, + {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.11.8" @@ -2127,4 +2252,4 @@ tomli = ">=2.4.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "d80d76a1d975fc810e59f1f3dd32acb40d17f06539f1c9ac2bfa8ac09923c8cb" +content-hash = "11d58954fa95ccfda69482e9b9f98c6b353b358999f725966da0fa5b9459f9dc" diff --git a/pyproject.toml b/pyproject.toml index 6ec3e1310..efd06901b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ jinja2 = "^3.1.6" # release. This narrowness is intentional -- bump it manually and re-run # `task build` plus `tools/check_zensical_output.py` when doing so. zensical = "^0.0.62" +pillow = "^11.3.0" [tool.poetry.group.dev.dependencies] linkcheckmd = "^1.4.0" diff --git a/tasks/backlog.md b/tasks/backlog.md index 5dea765fe..a0e658bb7 100644 --- a/tasks/backlog.md +++ b/tasks/backlog.md @@ -1,270 +1,360 @@ -# Backlog: temporary tag-listing renderer +# Backlog: print-on-demand book block -Work breakdown for [spec.md](spec.md). **Removed 2026-09-03** - see spec.md's Removal -section; superseded by Zensical 0.0.58's native `tags` listings. +Work breakdown for [spec.md](spec.md). **Status 2026-09-15: P0-P12 done and verified, P13-P17 open.** -**B0-B8: done** (2026-08-23). Listings render on `/tags/` and `/tutorials/`, guarded by -three required checks, 30 unit tests, `task preview` added for accurate previews. +The previous content of this file (the temporary tag-listing renderer) is in the git history. -**C1-C4: done** (2026-08-23). Every page tag chip links to its section on `/tags/` - -703 chips across 531 pages - guarded by two more required checks and 10 more unit tests. -Q6 settled as recommended. +Every task names how it is verified. A task is not done until that verification passes. --- -## B0 — Decide the open questions +## P0 - Decisions - done -Spec §5. My recommendations, all "match production / fail loudly": +Spec §4, D1-D11: BoD, A4, black and white on 80 g, no ISBN, GitHub IDs as authors, separate screen and +print editions, section modes with A.3 and Release Notes as lists, page references and URL footnotes, +no logo or version on text pages, 10 pt body, optional Ghostscript normalization. -| # | Question | Recommendation | -|---|---|---| -| 1 | Icons for the 14 unmapped tags | Render plain, as production does | -| 2 | `Graph-Insights` vs `GraphInsights` duplicate | Out of scope; separate content fix | -| 3 | `Load Balancer` mapping missing from `HEAD` | Restore the two `mkdocs.yml` lines | -| 4 | Marker present but unrenderable | Fail the build | -| 5 | `/tags/` size (45 sections, 531 refs) | Include everything | +--- + +## P1 - Print edition - **done** + +`task pdf:print` builds `dist/documentation-eccenca-com--print.pdf` next to the screen PDF: +the same merge and pandoc run, Typst compiled with `--input edition=print`. `style.typ` reads the input +once and branches where the editions differ. `task pdf` stays as it is. + +**Verify:** both PDFs build; the screen PDF still has 1680 pages and an unchanged page 4. +**Est:** small. **Depends on:** nothing. -**Output:** decisions recorded in the spec. **Est:** one review pass. +**Done 2026-09-15:** `dec-tool build-pdf --edition print` (`PDF_EDITION`), intermediates in +`dist/pdf/print/`, flag `print-edition` in `style.typ`. Against a baseline built from `c20d74b94` +before any change: the screen PDF has 1680 pages, the text of all pages is identical and page 4 differs +by 0 pixels. The print edition builds (1585 pages at that point). --- -## B1 — Tag index builder +## P2 - Mirrored page geometry - **done** -Walk `docs/**/*.md`, parse front matter, build `{tag: [(title, src_path)]}`. +A4, `binding: left`, margins as `inside`/`outside` in the print edition; the peach bands' outset +mirrors with them. -- title = front-matter `title:`, else first body `# ` heading, else skip with a warning -- skip pages with no `tags:` -- tolerate malformed YAML without crashing the build -- pure function over `docs/`, no `site/` knowledge — keeps it unit-testable +**Verify:** render a spread (an even and the following odd page): the text blocks mirror, and the +text width is unchanged. +**Est:** small. **Depends on:** P1. -**Verify:** 531 tagged pages, 45 distinct tags, `TransformOperator` = 237. -**Est:** small. **Depends on:** nothing. +**Done 2026-09-15:** inside 3.0 cm, outside 2.0 cm. The header of the print edition is empty (P3), so +its top margin is 2.5 cm instead of 3.9 cm; the bands' outset is symmetric and needs no mirroring. +Measured with `pdftotext -bbox` on pages 20 and 21: even page margins 2.00 cm left and 3.00 cm right, +odd page 3.00 cm left and 2.00 cm right, text width 16.00 cm on both. --- -## B2 — Marker parser +## P3 - Running titles and page numbers - **done** -Recognise both forms in built HTML and extract the filter: - -``` - -> no filter - -> include=[BeginnersTutorial] -``` +- verso: page number at the outer left, part title; recto: page title, page number at the outer right +- no `| total`, no logo, no version stamp on text pages +- none on the title page, imprint, part covers and blank pages -The argument is YAML-ish but not valid YAML (unquoted `[X]` inside braces parses fine, but -do not assume). Parse defensively; an unrecognised argument is an error, not a silent -no-filter. +**Verify:** a script reads `pdftotext -bbox` and asserts, for every page with a number, x below 20 % +of the page width on even pages and above 80 % on odd pages. Render two spreads. +**Est:** medium - the footer queries already exist; the parity and the exclusions are new. +**Depends on:** P2. -**Verify:** finds exactly 4 markers across 2 files in the current build. -**Est:** small. **Depends on:** nothing. +**Done 2026-09-15:** `print-footer()` and `bare-page()` in `style.typ`; the print header is empty. The +check over all 1604 pages of the print edition: 1575 pages carry a footer and each has its page number +at the outer edge (left on even, right on odd pages); the 29 pages without one are exactly the title +page, the imprint, the 9 part covers and the 18 blank pages. The checker becomes part of the preflight +report (P14). --- -## B3 — HTML renderer +## P4 - Recto starts and blank pages - **done** -Emit the markup in spec §3 for one listing. +- title page 1, imprint 2, front contents 3 +- part cover, part contents and part text each start recto +- a blank page has no furniture: a state set by the page break, read by header and footer +- total padded to even -- anchor id `tag:` + lowercase, spaces → hyphens -- chip class from `extra.tags`; bare `md-tag` when unmapped (pending Q1) -- relative href from the listing page to each target -- escape titles -- ordering per spec §3 +**Verify:** every part cover and the front contents on an odd page; every blank page has no text in +`pdftotext`; page count even. +**Est:** medium - suppressing furniture on inserted blank pages is the fiddly part (spec §1). +**Depends on:** P3. -**Verify:** byte-compare one rendered block against the production sample in the spec. -**Est:** medium — the relative-URL computation is the fiddly part. -**Depends on:** B1, B2, Q1. +**Done 2026-09-15:** `recto-break()` brackets `pagebreak(weak: true, to: "odd")` with two metadata +markers, and a page strictly between them counts as blank. Typst cannot pad to an even count itself - +a page break that depends on the page count never converges - so the build reads the unpadded count +with `typst eval` (`unpadded_pages`) and compiles with `--input pad=true` when it is odd (1603 → 1604). +Checked on all pages: title page 1, imprint 2, front contents 3; the 9 part covers and the 9 part +contents start on odd pages; 18 blank pages, all even, all without text, never three in a row. --- -## B4 — `tools/render_tag_listings.py` +## P5 - Title page - **done** -Wire B1–B3 into a CLI matching `localize_bundle_assets.py`'s shape: takes `[site_dir]`, -prints `[OK]` lines per marker, exits non-zero with a problem list. +Publisher **eccenca GmbH** on the title block; site link and copyright move to the imprint. -- reads `mkdocs.yml` for `extra.tags` and the two `listings_*_sort_by` settings -- idempotent: re-running on an already-rendered `site/` is a no-op, not an error - (`localize_bundle_assets.py` needed this and it was easy to get wrong) -- fails if any marker remains after processing +**Verify:** render page 1. +**Est:** trivial. **Depends on:** P4. -**Verify:** `python tools/render_tag_listings.py site` twice in a row, second run clean. -**Est:** small once B1–B3 exist. **Depends on:** B1, B2, B3, Q4. +**Done 2026-09-15:** rendered page 1: logo, the house title block and version, the publisher from +`tools/pdf/print.yml` at the foot of the page; no date, link or copyright. The screen title page is +unchanged. --- -## B5 — Wire into the build +## P6 - Author list - **done** + +`dec-tool pdf-authors` (run by `task pdf:authors`) writes `tools/pdf/authors.yml`: GitHub ID and +commits, most commits first, ties by ID case-insensitive. + +- source: the GitHub contributors API without anonymous entries +- excludes accounts of type `Bot` and agent IDs matching `claude` or `codex` +- committed, so the PDF build stays offline and reproducible and a changed list shows in review -Add to `build` in `Taskfile.yml`, after `zensical build --strict` and alongside -`localize_bundle_assets.py`. Order relative to the localizer does not matter — they touch -disjoint files — but keep the localizer first so the more security-relevant step runs -regardless. +**Verify:** unit tests for ordering, tie-break and exclusion; the generated file matches the spec §3 +table (22 IDs). +**Est:** small. **Depends on:** nothing. -**Verify:** `task clean build` renders listings; `task check` still passes. -**Est:** trivial. **Depends on:** B4. +**Done 2026-09-15:** `tools/pdf_authors.py`, `tests/test_pdf_authors.py` (4 tests: order and tie-break, +exclusion of bots, agents and anonymous entries, pagination, file format), `task pdf:authors`. The +generated `tools/pdf/authors.yml` lists the 22 IDs of spec §3 in the same order and passes yamllint - +list items are indented, which PyYAML's default dumper does not do. --- -## B6 — Promote the guard +## P7 - Imprint - **done** + +Page 2 as spec R3, with the publisher address `eccenca GmbH, Hainstraße 8, 04109 Leipzig, Germany`, no +ISBN. Typst reads `tools/pdf/authors.yml` with `yaml()`; edition, commit and date come from the +existing `--input` values. -In `check_zensical_output.py`, `tag-listings` currently sits in PENDING and reports -unexpanded markers. Once we render them ourselves it becomes a feature we own, so it -belongs in REQUIRED — same reasoning as redirects and comments. +**Verify:** render page 2; the author order matches `authors.yml`. +**Est:** small. **Depends on:** P5, P6. -Keep a separate PENDING probe for "Zensical started doing this itself", so the removal -signal in spec §6 still fires. Distinguishing the two is the fiddly bit: our own output and -Zensical's would both look like a populated listing. Suggest keying the PENDING probe on a -marker being *already expanded before* our script runs. +**Done 2026-09-15:** `imprint()` in `style.typ`, set at the foot of page 2: edition stamp with commit, +publisher and address from `tools/pdf/print.yml`, the 22 author IDs, licence and copyright, the online +edition - with a sentence that the print edition shortens sections whenever `print.yml` lists one as +`list` or `omit` - and the typesetting. Checked on the rendered page and in its text: the author IDs match +`authors.yml` in order. The licence URL is set as a string, because Typst links URLs written in markup. + +--- -**Verify:** deliberately skip the render step; `task check` must fail. -**Est:** medium — mostly deciding the probe. **Depends on:** B4, B5. +## P8 - Section modes - **done** + +`tools/pdf/print.yml` maps `nav.yml` section paths to `full`, `list` or `omit`; `tools/build_pdf.py` +reads it for the print edition only. + +- `omit`: the section's pages are not merged; the part contents name the online edition instead +- `list` for the operator reference (`build/reference/`): merge the section page and the five overview + pages, drop their children; links from the overview tables to dropped pages print as plain text +- `list` for Release Notes (`release-notes/`): replace the release pages with one generated table, + Release | Summary, the summary being the release page's first paragraph or, without one, its component + headings +- a section in `list` or `omit` mode starts with a sentence naming its online URL +- an unknown path or mode in `print.yml` fails the build + +**Verify:** unit tests per mode on small fixtures; with the default configuration A.3 takes about +17 pages and Release Notes about 3; switching either to `full` restores today's pages; no internal +link targets a dropped page. +**Est:** medium - the release summary fallback and the links into dropped pages are the fiddly parts. +**Depends on:** P1. + +**Done 2026-09-15:** `load_section_rules`, `apply_section_rules` (`list_section`, `omit_section`) and +`render_generated` in `tools/build_pdf.py`; `tests/test_build_pdf_print.py` (10 tests). A `list` section +keeps its own page and its subsections' overview pages; a page no overview lists goes into a table under +its heading, one per run of pages. Release Notes have no overview pages, so they become 8 tables - one +per year - with the 22 releases. Measured on the print edition: A.3 takes 17 pages (pp. 23-39, 389 pages +dropped), the Release Notes part 6 pages including cover, contents and blank pages; the book goes from +1604 to 1012 pages. Setting both sections back to `full` gives 1604 pages again. The two notes name +`…/26.2/build/reference/` and, since `release-notes/` has no page, the first release page. Links from +the overview tables to dropped pages print as text; 105 links from other pages to dropped operator +pages lead to the published site - a dropped page has no label, so no internal link can target one. --- -## B7 — Tests +## P9 - Links on paper - **done** -`tests/test_render_tag_listings.py`, following `tests/test_update_di_reference.py`. +In the print edition: internal links print their text plus `(p. N)`; external links print their text +with a footnote holding the URL; no colour, underline, arrow or link annotation. -- title resolution: front matter wins over H1; H1 fallback; neither → warn -- slugification: `Load Balancer` → `tag:load-balancer` -- sort orders: casefold for tags, title for items, marker order for `include:` -- unmapped tag → no `md-tag-icon` class -- marker parsing: both forms, plus a malformed one -- relative hrefs from different depths +**Verify:** sample pages; the number of footnotes equals the number of external links in the printed +sections; no page reference is `p. 0`; `strings` finds no `/Annots` in the book block. +**Est:** medium - thousands of page lookups; watch for Typst's "layout did not converge" warning. +**Depends on:** P1, P8. -**Verify:** `task test:unit` stays green. -**Est:** medium. **Depends on:** B1–B4. +**Done 2026-09-15:** the print branch of `show link` in `style.typ`; contents entries are laid out +without their link. Of 524 external links, 73 print their address as their own text and get no +footnote; 451 get one, 2 of them repeating the address their text already shows. 2154 page references, +none `p. 0`. A reference printed only for a target on another page never let the layout converge - it +moves lines, which moves the target back - so it is printed always, and the build now has no +convergence warning. Deviation from the check above: Typst's own footnotes link marker and entry, so +the book block keeps 902 internal link annotations; none of them leads to an address (`/S /URI`: 0). +The optional Ghostscript pass (P13) removes them. --- -## B8 — Documentation +## P10 - Typography for print - **done** -Extend the README section added during the migration. It currently lists tag listings among -the three missing features; that becomes "reimplemented locally, tracked for removal", -leaving social cards and revision dates as the genuinely-missing two. +- paragraph spacing 1.2 em and block spacing 1.0 em (measured −6.5 % pages) +- hyphenation on, widow and orphan costs +- a 6 pt floor for shrinking terminal tables +- measure Regular instead of Light for the body; decide with a greyscale print sample +- body stays 10 pt; 9 or 8 pt only as a fallback if the preflight page limit is exceeded (D10) -**Est:** trivial. **Depends on:** B5. +**Verify:** page count after each change; render pages with justified text. +**Est:** small. **Depends on:** P1. ---- +**Done 2026-09-15:** paragraph spacing 1.2 em and block spacing 1.0 em take the book from 1012 to 962 +pages; hyphenation on for body text, off for titles; code in a shrinking terminal table never below +6 pt. Typst's defaults already cost widows and orphans at 100 % (`text.costs`, checked), so they needed +no setting. A Regular body costs 2 pages (963 instead of 961 before padding); `body-weight` in +`tools/pdf/print.yml` switches it, and it stays `light` until a printed sample decides. Sample pages 26 +and 268 checked on the render: justified text without gaps, code and terminal table legible. -## Sequencing +--- -``` -B0 ─┬─> B1 ─┬─> B3 ──> B4 ──> B5 ──> B6 ──> B8 - └─> B2 ─┘ └──> B7 -``` +## P11 - Black-and-white palette - **done** -B1 and B2 are independent and can start as soon as Q1/Q4 are settled. +- grey areas at least 20 % black (BoD): `ec-wash` code and note grounds, peach bands - raise to 20 % or + replace fills with rules +- print-edition values for orange, link blue and the admonition accents that stay distinguishable in + grey +- check the red frames of the four part diagrams -## Risks +**Verify:** render sample pages with the greyscale preview of P13 and compare with the screen +edition; the preflight measures the lightest fill. +**Est:** small. **Depends on:** P1. -- **Relative URL computation** is where this most likely breaks — mike serves the site under - `/latest/` and `/26.2/`, so anything absolute fails silently in one context. Acceptance - requires resolving every generated link against `site/` on disk. -- **`/tags/` is large.** 531 references in one page. Watch build time; if it becomes - noticeable, that is an argument for revisiting Q5. -- **Divergence from production markup.** We match it today, but a Material update could - change the markup and this becomes a slow drift. Mitigated by the whole thing being - temporary and by the spec pinning a production sample. -- **The removal signal is the weak point** (B6). If it never fires, this "temporary" script - becomes permanent. Worth a calendar reminder to re-check backlog #38 rather than relying - only on the probe. +**Done 2026-09-15:** in print, text and alarm accents are black, the orange a dark grey, the peach bands +20 % black; code, code spans and admonitions have no ground - a thin frame marks a code block, the bar on +the left an admonition. The greyscale preview of P13 does not exist yet, so Ghostscript's `pnggray` +rendered all 962 pages instead: on the 610 pages without images no area is lighter than 20 % black, and +the bands measure 204 (20 % black) on 36 pages. The part diagrams' red frames are part of their images and +stay visible as a dark frame on the covers. --- -# Part 2: linked tag chips (spec §9) +## P12 - Print images - **done** -Rendering the listings created 45 anchors that nothing points at. Material links every -per-page tag chip to its section on `/tags/`; Zensical emits inert ``s. Measured: -**531 pages, 703 chips, 0 links.** +In `tools/build_pdf.py`, for the print edition: every image Typst embeds is a normalized copy in +`dist/pdf/images/`. -Smaller than Part 1 and a different mechanism - a **template override**, not another -post-build pass, so it also works under `task serve`. +- alpha composited onto white, so the book block carries no transparency +- resampled (Lanczos) to 300 ppi at its printed width: text width (16 cm) when the image is wider than + the column, its natural size otherwise +- no longer scaled up beyond its natural size (spec §6) +- originals under `site/` untouched; cached by content hash, so a rebuild does not resample again -## Q6 — Decide before coding — **done**, all as recommended +Needs an image library. Pillow is importable today only as a transitive dependency (11.3.0) and is not +declared in `pyproject.toml` - declare it before relying on it. ImageMagick is the alternative: installed +locally, not yet in CI. -Spec §9 open questions: +**Verify:** unit tests on fixture PNGs (alpha, small, large); in the book block `pdfimages -list` shows +no soft mask and no image below 300 ppi; render pages with former low-resolution screenshots. +**Est:** medium - the printed-width rule must match how Typst sizes images. **Depends on:** P1. -| # | Question | Recommendation | -|---|---|---| -| 1 | Slug computed twice (MiniJinja + Python) | Accept the duplication, but assert reachability in the check | -| 2 | Tag whose only page is the listing page itself → dead anchor | Covered by the same assert; no special case | -| 3 | `hide: tags` pages | Keep the existing template branch; no decision | +**Done 2026-09-15:** `print_image` and `printed_width_pt` in `tools/build_pdf.py`, Pillow declared in +`pyproject.toml` (it was installed only as a leftover of CairoSVG); `tests/test_build_pdf_images.py` +(7 tests). The printed width follows how Typst sizes an image, measured in a probe: a percentage of the +16 cm column, otherwise pixels × 72 / declared dpi (72 without one), never wider than the column and +never scaled up - so "no longer scaled up" needed no change. The copy declares 300 dpi and keeps that +printed width. In the book block: 586 images, 0 soft masks, the lowest at 300 ppi, none below; 571 +copies in `dist/pdf/print/images/`. The PDF grows from 72 to 165 MB with the lossless upsampled copies. -**Est:** one review pass. +--- -Decision: go with all above recommendations! +## P13 - Normalization pass (optional) ---- +`dec-tool pdf-normalize ` (run by `task pdf:print` with `--normalize` / `PDF_NORMALIZE=1`), spec §7: -## C1 — `overrides/partials/tags.html` — **done** +- Ghostscript pdfwrite with `-dPDFX=4`, CMYK conversion, bicubic downsampling of colour and grey + images above 300 dpi, Flate re-encoding, `-dPreserveAnnots=false` +- prefix file `tools/pdf/PDFX_def.ps`, derived from Ghostscript's `lib/PDFX_def.ps`, with title and + a FOGRA39 output intent; the profile passed with `--permit-file-read` +- ISO Coated v2 (FOGRA39) profile vendored under `tools/pdf/icc/` with its ECI licence, listed in the + README asset table - or downloaded in CI if the licence does not allow vendoring +- a `--gray` preview variant for P11 +- fails when the output lacks `GTS_PDFXVersion (PDF/X-4)` - Ghostscript silently falls back to plain + PDF when a page still carries an annotation +- never `-sOutputICCProfile` together with `-dPDFX` (crashes, truncated file) -Copy Zensical's `partials/tags.html` verbatim and replace only the `tag.url` branch: +**Verify:** on the full book block: PDF/X-4 marker in info and XMP, one output intent naming FOGRA39, +`pdfimages -list` shows only `cmyk` images and none above 300 ppi, `pdffonts` all embedded, page count +unchanged; render sample pages before and after and compare. +**Est:** small - the recipe is verified on 38 pages. **Depends on:** P9 (no annotations), P12. -```jinja -{% set anchor = "tags/" | url ~ "#tag:" ~ (tag.name | lower | replace(" ", "-")) %} -{{- tag.name -}} -``` +--- -Keep everything else byte-identical, as `tabs-item.html` does, so it can be re-synced. -Retain the `hide: tags` branch and the `md-tag-shadow` / `md-tag--` class logic. +## P14 - Preflight report -**Done:** output is byte-identical to production's, e.g. -`Configuration`. -The override differs from Zensical's stock template by exactly the one branch. +`dec-tool pdf-preflight `: A4 page size, page count at most 1,200 and even, fonts embedded, images +below 300 ppi, soft masks, lightest fill below 20 % black, page-number position (P3's check), blank +pages blank. Non-zero exit on a violation; run by `task pdf:print`, on the normalized file when P13 ran. ---- +**Verify:** unit tests on small fixture PDFs; deliberately break one rule, the report must fail. +**Est:** medium. **Depends on:** P3, P4, P8. -## C2 — Assert every chip anchor resolves — **done** +--- -New required check in `check_zensical_output.py`: for each `href="…/tags/#tag:X"` in the -output, `#tag:X` must exist on `/tags/`. This is what keeps the MiniJinja slug and -`render_tag_listings.tag_slug()` from silently drifting apart. +## P15 - Low-resolution originals -Cheap: collect the anchor ids from `/tags/` once, then set-compare against the hrefs. +49 images are below 150 ppi at printed size; resampling (P12) hides that from the preflight but adds no +detail. Work through the list: replace with a fresh screenshot, or accept in spec §8. -**Done:** `tag-chips-linked` (703 chips on 531 pages) and `tag-chips-resolve` (every -anchor exists), both required. Verified by removing `replace(" ", "-")` from the template: -the check failed on `#tag:load balancer` and `#tag:application view`, the only two -multi-word tags. +**Verify:** every entry replaced or accepted. +**Est:** medium, mostly content. **Depends on:** P12. --- -## C3 — Tests — **done** +## P16 - CI -Extend `tests/test_render_tag_listings.py`, or a sibling, with the slug-parity case: the -Python `tag_slug()` and the template's expression must agree for every tag in use - -including `Load Balancer` (space) and `Graph-Insights` (existing hyphen). +`.github/workflows/pdf.yml` also builds the normalized book block and uploads it with the preflight +report, as a separate artifact. Installs Ghostscript on the runner. -Template rendering itself is covered by C2 against the real build rather than by unit test; -MiniJinja is not worth mocking for three filters. - -**Done:** 10 tests reading the override and asserting its slug expression matches -`tag_slug()`. 59 tests pass in total. +**Verify:** a push to `main` produces both artifacts. +**Est:** small. **Depends on:** P1, P13, P14. --- -## C4 — Documentation — **done** +## P17 - Cover (separate deliverable) -README: the "reimplemented here" table gains a row for the chip links, pointing at the same -`#38` and the same removal trigger. Spec §7 already lists the override for deletion. +Front, spine and back; the spine width follows from the final page count and 80 g paper. Tracked here +so it is not forgotten; it has its own spec. -**Done.** +**Depends on:** P8, P14. --- ## Sequencing +```text +P1 ──> P2 ──> P3 ──> P4 ──> P5 ──> P7 + │ │ P6 ──┘ + ├──> P8 ──> P9 ──────┼─────────────┐ + ├──> P10 │ │ + ├──> P11 │ │ + └──> P12 ──> P15 │ │ + └──────────────┼──> P13 <────┘ + └──> P14 ──> P16 +P17 after P8 and P14 ``` -Q6 ──> C1 ──> C2 ──> C3 - └──> C4 -``` + +P1, P6, P8, P10, P11 and P12 can start now. ## Risks -- **Slug drift** between the template and the Python renderer is the only real one, and C2 - exists specifically to make it loud. Do not skip C2 to save time. -- **Chips link to `/tags/`, which is a large page.** 45 sections, 531 references. Jumping - to an anchor there is fine, but it is a heavy page to load from a chip click. Matches - production, so not a regression - noting it because it is the sort of thing that gets - raised later as if it were new. -- **Override drift** if Zensical changes its `tags.html`. Same exposure as - `tabs-item.html`; mitigated by keeping the copy byte-identical apart from the one branch. +- **The page budget is an estimate.** It adds measured savings that were taken separately; P8 and P10 + must re-measure, and P14 enforces the limit. +- **Ghostscript falls back to plain PDF silently.** One annotation left anywhere and the output is not + PDF/X; P13 checks the marker instead of trusting the exit code. +- **CMYK conversion and downsampling change screenshots.** Compare renders before and after P13 on pages + with dense UI text; Flate keeps the re-encoding lossless, the bicubic resampling does not. +- **Upsampled images look sharper in the preflight than on paper.** P15 exists because P12 cannot add + detail that is not there. +- **Black and white loses colour cues** in screenshots and in the part diagrams' red frames, and today's + light fills fall below BoD's 20 % rule; only a greyscale sample shows how much. +- **Page references cost compile passes.** Thousands of `(p. N)` lookups can move page breaks that move + page numbers; Typst stops after five layout attempts and warns. +- **80 g paper is for publishers only at BoD.** Without a publisher account the book prints on 90 g, + where the limit is 1,050 pages. +- **The screen PDF must not drift.** Every print change goes behind the `edition=print` switch (P1), and + the screen PDF's page count is part of every verification. diff --git a/tasks/spec.md b/tasks/spec.md index ad9359c9e..843af395d 100644 --- a/tasks/spec.md +++ b/tasks/spec.md @@ -1,267 +1,371 @@ -# Spec: temporary tag-listing renderer +# Spec: print-on-demand book block -**Status:** accepted 2026-08-23, fully implemented §1-§9, **removed 2026-09-03**. -Zensical 0.0.58 ships native `tags` listings and populates `tag.url` itself; the -"Removal" steps below were carried out as part of the 0.0.57 -> 0.0.58 bump. -**Replaced:** Material's `tags` plugin listings, which Zensical did not implement -([zensical/backlog#38](https://github.com/zensical/backlog/issues/38)). -**Lifetime:** delete the moment Zensical ships listings. See "Removal" below. +**Status:** accepted 2026-09-14 - all decisions made (§4). Backlog P0-P12 implemented 2026-09-15 +(`task pdf:print`, 962 pages); P13-P17 open. The backlog's "Done" notes record where the implementation +refines this spec. +**Branch:** `feature/print-on-demand`, based on `main` at `c20d74b94` (PDF export merged). +**Goal:** a *book block* - the interior file of a printed, perfect-bound book - built next to the +screen PDF, which BoD accepts without rework. +**Not in scope:** the cover file (front, spine, back); see §9. ---- - -## 1. Problem - -`tools/check_zensical_output.py` reports this every build: - -``` -[PEND] tag-listings: /tags/ article has 3 words, 4 unexpanded - marker(s) on 2 page(s) (backlog #38) -``` - -Per-page tag *chips* work — Zensical renders them, and the icon CSS with them. What is -missing is the *listings*: the generated index of which pages carry which tag. - -Two pages are affected, carrying four markers between them: - -| Page | Marker | Renders today | -|---|---|---| -| `docs/tags.md` | `` | nothing — page body is just its title | -| `docs/tutorials/index.md` | `` | nothing | -| `docs/tutorials/index.md` | `` | nothing | -| `docs/tutorials/index.md` | `` | nothing | - -`/tutorials/` is the more visible loss: it is a landing page whose entire purpose is the -generated list, and it currently shows an intro paragraph followed by blank space. - -## 2. Approach - -**Post-build HTML injection**, mirroring the existing `tools/localize_bundle_assets.py`. - -Zensical passes the markers through to the output verbatim as HTML comments: - -```html - - -``` - -so they are addressable in `site/**/*.html` after the build. A script walks the corpus for -tag front matter, renders the listing HTML, and substitutes it for each marker. - -### Why this shape +The previous content of this file (the temporary tag-listing renderer, removed 2026-09-03) is in the +git history. -- **Sources stay untouched.** The pages keep Material's own marker syntax, so when - Zensical implements listings they light up natively and this script is deleted. No - migration back. -- **Precedent in-tree.** `localize_bundle_assets.py` already post-processes `site/` inside - `task build`, with the same "assert loudly if the expected pattern is missing" contract. -- **No new dependencies, no template overrides.** Icon styling already works (below). - -### Alternatives rejected - -| Option | Why not | -|---|---| -| Pre-build: expand markers into `docs/*.md` | Mutates tracked sources; dirty tree after every build | -| Generate a partial + `--8<--` snippets include | Requires editing both pages away from Material syntax, then back later; needs two-phase build | -| Override a Zensical template | Zensical has no listings template to override — the feature is absent, not broken | -| Write a Zensical plugin | Zensical has no plugin API | - -## 3. Output contract - -Reproduce production's markup exactly. Sample from -`https://documentation.eccenca.com/latest/tutorials/`: - -```html -

-BeginnersTutorial¤

- -``` - -### Rules, all verified against production +--- -**Anchor id** — `tag:` + tag lowercased with spaces replaced by hyphens. -`Load Balancer` → `tag:load-balancer`, `Graph-Insights` → `tag:graph-insights`. +## 1. Starting point + +Measured on the level-4 build of `main` (`dist/documentation-eccenca-com-26-2.pdf`). + +| Aspect | Today | +| :-- | :-- | +| Size | 1680 pages, A4, 118 MB | +| Type | body 10 pt Roboto Light (`size-base`, one step below the house style's measured 11 pt); tables 8.6 pt, admonitions and cards 9.1 pt, code 7.7 pt; 3 terminal tables shrink to fit, the widest (114 columns) to about 6.3 pt | +| Line length | about 104 characters per full body line (75th percentile, pages 40-80) | +| Parts | A Build pp. 3-734 (732) · B Explore 735-861 (127) · C Consume 862-900 (39) · D Distribution 901-910 (10) · E Deploy and Configure 911-1075 (165) · F Automate 1076-1192 (117) · G Develop 1193-1490 (298) · H Release Notes 1491-1677 (187) · I Tutorials 1678-1680 (3) | +| Largest blocks | A.3 Task and Operator Reference pp. 23-480 (458) · G Develop 298 · H Release Notes 187 | +| Geometry | symmetric margins: left/right 2.5 cm, top 3.9 cm, bottom 2.2 cm (`style.typ`, `set page`) | +| Header | logo left on every page; `Version 26.2` / `Generated ()` right on every page but page 1 | +| Footer | running title (`A Build › A.3 …`) left, `Page n \| total` right, orange, on every page but page 1 | +| Pagination | page 1 title page, page 2 parts contents; a part starts on whichever page follows | +| Spacing | paragraph spacing 2.24 em, block spacing 1.52 em (house style) | +| Links | 3193 internal jumps, 614 external links plus 70 into the published site; blue, underlined, external ones with `↗`; no footnotes | +| Images | 641, all ICC-based RGB; 565 carry a soft mask (transparency). Effective resolution in the print scope (§5), 586 images: 331 below 300 ppi, 144 below 200, 49 below 150 | +| Colour | orange, peach, slate and link blue; grey code ground; red for danger admonitions; the part diagrams mark their part with a red frame | +| Text | justified, hyphenation off | +| Tables | 273 header rows emitted by pandoc as `table.header`, which Typst repeats on every page | +| PDF | Typst 0.15.1 writes PDF 1.4-2.0, PDF/A and PDF/UA, **not PDF/X**; Ghostscript 10.08 is available (§7) | + +Verified in a Typst 0.15.1 probe: `set page(binding: left, margin: (inside: …, outside: …))`, +`pagebreak(to: "odd")`, `calc.odd(here().page())` in header and footer, `text(costs: (widow: …, +orphan: …))` and `footnote`. A blank page inserted by `pagebreak(to: "odd")` **still gets header and +footer**; suppressing them needs its own rule. + +## 2. Provider: BoD + +Decided (D2). Researched 2026-09-14; re-check the current specification before ordering. + +| Requirement | BoD | Source | +| :-- | :-- | :-- | +| Pages, A4 paperback | 1,200 on 80 g, 1,050 on 90 g white, 900 on 90 g cream, 120 g or 130 g | Buch drucken | +| Paper | white or cream 80 g is **for publishers only**; 90 g for everyone | Grafiken und Farbmanagement | +| Image resolution | at least **300 dpi** at printed size; line art without grey levels (bitmap) **1200 dpi** | Grafiken und Farbmanagement | +| Grey areas | at least **20 % black**, not lighter; grey steps in charts at least 20 % apart | Grafiken und Farbmanagement | +| Transparency | to be reduced before delivery; otherwise BoD reduces it, which "can change objects" | Grafiken und Farbmanagement | +| Colour | RGB recommended - BoD converts to CMYK for its presses; untagged RGB is read as sRGB, CMYK as ISO Coated v2 (FOGRA39); no colour separation | Grafiken und Farbmanagement | +| Bleed | 5 mm per side, only for content that runs off the page; no crop marks | Grafiken und Farbmanagement | +| Fonts and images | embedded | Hilfe | + +Sources: +[BoD Buch drucken](https://www.bod.de/buch-drucken.html), +[BoD Grafiken und Farbmanagement](https://www.bod.de/bodfiles/GLOBAL-Storage/documents/help-documents/bod-grafiken-und-farbmanagement.pdf), +[BoD Hilfe](https://www.bod.de/hilfe/hilfe-und-service.html). + +For comparison, not pursued: Amazon KDP allows 828 pages (black ink on white), Lulu 800. + +## 3. Requirements + +### R1 - Page furniture for verso and recto pages + +- Page numbers sit on the **outer edge**: left on even (verso) pages, right on odd (recto) pages. +- Running titles mirror too, following book convention (the larger unit on the left page): + - verso: `1234` at the outer left, `A Build` beside it + - recto: `A.3 Task and Operator Reference` beside `1235` at the outer right +- `| total` is dropped - it has no meaning on paper. +- No logo and no version stamp on text pages (D9); both stay on the title page and the imprint. +- Margins become `inside`/`outside` with `binding: left` on A4 (D7). The gutter must grow with the + spine; a starting proposal is inside 3.0 cm, outside 2.0 cm, which keeps today's 16 cm text width. +- No header and no footer on the title page, the imprint, blank pages and part covers. + +### R2 - The title page is page 1 of the book block + +- Page 1, recto. It keeps the house title block (eyebrow, title, version) and names the publisher, + **eccenca GmbH**. +- The site link and the copyright line move to the imprint. +- Counted as page 1, number not printed. + +### R3 - Imprint on page 2 + +Page 2, verso, no page furniture. Contents: + +- title and edition: `eccenca Corporate Memory - Documentation, Version 26.2`, print edition, + generated `` from commit `` (moves here from the running header) +- publisher: **eccenca GmbH, Hainstraße 8, 04109 Leipzig, Germany** +- authors: the list below +- licence: CC BY-SA 4.0, as `README.md` states, with the copyright line from `mkdocs.yml` +- online edition: `https://documentation.eccenca.com/26.2/`, named as the complete reference for what + the print edition shortens (§5) +- colophon: typeset with Typst from the Markdown sources; fonts Roboto, Roboto Mono, Noto Color + Emoji, DejaVu +- no ISBN (D3) + +#### Authors + +Rule (D4): GitHub accounts of the contributors to +[eccenca/documentation.eccenca.com](https://github.com/eccenca/documentation.eccenca.com), **most +commits first**, printed as their **GitHub IDs**. Not listed: anonymous contributions (commits whose +e-mail maps to no GitHub account), bot accounts and software agents (codex, claude). Ties are ordered by +ID, case-insensitive. + +Measured with `gh api repos/eccenca/documentation.eccenca.com/contributors` on 2026-09-14: + +| # | Commits | GitHub ID | +| --: | --: | :-- | +| 1 | 639 | rpietzsch | +| 2 | 532 | seebi | +| 3 | 66 | irangareddy | +| 4 | 62 | muddymudskipper | +| 5 | 61 | mgns | +| 6 | 53 | sobo | +| 7 | 29 | annamakor | +| 8 | 25 | msaipraneeth | +| 9 | 22 | saradaimi786 | +| 10 | 20 | louiswesterheide | +| 11 | 18 | robertisele | +| 12 | 12 | spl0tt | +| 13 | 11 | white-gecko | +| 14 | 10 | BorderCloud | +| 15 | 10 | haschek | +| 16 | 9 | MaximilianWenzel | +| 17 | 7 | tomatophantastico | +| 18 | 5 | adelahaye-ecc | +| 19 | 1 | dgrtner-ecc | +| 20 | 1 | looooph | +| 21 | 1 | peterfreytag | +| 22 | 1 | pkgut | + +Dropped: two anonymous entries (33 and 2 commits). + +- **No software agent is in the list today.** Claude's commits carry a `Co-Authored-By` trailer and + count for the human author; GitHub does not list co-authors as contributors. The exclusion rule + still belongs in the tooling. +- GitHub counts commits on the default branch only. + +### R4 - Links on paper + +In the print edition (D8): internal links print their text plus a page reference `(p. 34)`; external +links print their text with a footnote holding the URL; no colour, underline or arrow. Link annotations +are left out of the book block - Ghostscript refuses PDF/X output while a page carries one (§7). + +## 4. Decisions + +All made 2026-09-14. + +| # | Question | Decision | +| :-- | :-- | :-- | +| D1 | Scope | The print edition reduces sections instead of printing them in full, **configurable per section** as `full`, `list` or `omit`. Default: A.3 Task and Operator Reference → `list`, H Release Notes → `list`, everything else `full` (§5) | +| D2 | Provider | **BoD** (§2) | +| D3 | ISBN | none for now; may come later | +| D4 | Authors | GitHub IDs, most commits first, no anonymous entries, no bots or agents (§3) | +| D5 | Colour and paper | black and white interior on 80 g paper. BoD offers 80 g to publishers only - eccenca GmbH needs a publisher account | +| D6 | Editions | separate screen and print PDFs. `task pdf` stays the screen edition, unchanged; `task pdf:print` builds the book block, switched by a Typst input (`edition=print`) and a print configuration | +| D7 | Trim size | A4 | +| D8 | Links on paper | page references and URL footnotes, no link styling (R4) | +| D9 | Logo and version in the running header | title page and imprint only (R1) | +| D10 | Body type size | keep 10 pt and tighten the spacing (§5). A smaller body, 9 pt or even 8 pt, only if the page limit is still exceeded - not needed for the default configuration | +| D11 | Normalization | an **optional** Ghostscript pass after Typst: PDF/X-4, CMYK, all images at 300 dpi (§7) | -**Tag chip class** — `md-tag md-tag-icon md-tag--` where `` is -`extra.tags[]` from `mkdocs.yml`. **Zensical already emits the backing CSS** -(`.md-tag.md-tag--beginners{--md-tag-icon:url(...)}`) on every page, so no CSS work is -needed. For tags absent from `extra.tags`, production omits `md-tag-icon` and the -`md-tag--` modifier, emitting a bare ``. 14 of the 45 tags in -use are unmapped — see §5. +## 5. Page budget -**Item title** — front-matter `title:` if present, otherwise the first `# ` heading in the -body. Verified on three pages; none of the sampled pages set `title:`, and all three -listing titles match their H1 exactly. +### Type size and spacing -**Item link** — relative from the listing page to the target, so it survives mike's -versioned `/latest/`, `/26.2/` prefixes. Never absolute. +Measured by compiling variants of the current book (`dist/pdf/book.typ`) with modified copies of +`style.typ`: -**Ordering** — from `mkdocs.yml`: -- tags within an un-filtered listing: casefolded tag name - (`listings_tags_sort_by: tag_name_casefold`) — confirmed, `/tags/` runs - AdvancedTutorial, API, Application View, Automate, BeginnersTutorial… -- items within a tag: page title (`listings_sort_by: item_title`) -- markers with `include:` render in **marker order**, not sorted — `/tutorials/` shows - Beginners, Advanced, Expert, matching source order +| Variant | Pages | Characters per line | +| :-- | --: | --: | +| today: 10 pt, paragraph spacing 2.24 em, block spacing 1.52 em | 1680 | 104 | +| body 9 pt | 1512 (−10 %) | 115 | +| paragraph spacing 1.2 em, block spacing 1.0 em | 1571 (−6.5 %) | 105 | +| both | 1414 (−16 %) | 115 | -**Scope** — a bare `` lists every tag in use (45 on `/tags/`). An -`include: [X]` marker lists only tag X. +At 10 pt a full line already holds about 104 characters, well above the 45-75 recommended for +continuous reading; 9 pt makes it 115, and thinner light strokes on black-and-white 80 g paper make it +worse. The spacing saves two thirds as much without touching legibility. -**Self-inclusion** — not an issue: neither `tags.md` nor `tutorials/index.md` carries tag -front matter, so neither can appear in its own listing. Guard anyway. +A two-column A4 layout would bring lines to a comfortable length, but wide tables, code blocks and +screenshots would need to break out of the columns. Not proposed. -## 4. Corpus facts +A heavier weight than Light for the print body (Regular) may print more reliably in black and white; +measure its page cost before deciding. -- 531 of 581 pages carry `tags:` front matter -- 45 distinct tags; largest is `TransformOperator` (237 pages), then `WorkflowTask` (77), - `PythonPlugin` (61), `cmemc` (47) -- The `/tags/` listing is therefore large — production's is ~2100 words +### Section modes -## 5. Open questions — resolved - -1. **Unmapped tags.** 14 tags in use have no `extra.tags` icon: `Build`, `Dataset`, - `DistanceMeasure`, `EvaluateTemplate`, `Explainer`, `Files`, `Graph-Insights`, - `GraphInsights`, `Introduction`, `LinkRules`, `Load Balancer`, `Plugin`, - `TransformOperator`, `WorkflowTask`. Match production (render plain, no icon), or take - the opportunity to map them? **Recommend: match production**, keep this change - behaviour-neutral. -> decision: match production, no icon +`full` prints the section as today. `omit` leaves it out; the part contents point to the online +edition. `list` reduces it to a two-column table: -2. **`Graph-Insights` vs `GraphInsights`** are both in use and look like an accidental - split — 2 tags where 1 was meant. Out of scope for this task, but the listing will make - it visible on `/tags/`. Worth a separate content fix. -> decision: separate fix +| Section | `list` renders | Measured | +| :-- | :-- | :-- | +| A.3 Task and Operator Reference | the section's own page and its five overview pages (Aggregators, Custom Workflow Tasks, Datasets, Distance Measures, Transformers), which already are generated `Name \| Description` tables; the 176 individual operator pages are dropped, and the navigation's operator categories under Transformers print as one **Category** \| **Transformers** table instead of empty headings | 458 → about 17 pages | +| H Release Notes | one table: **Release** (e.g. `Corporate Memory 26.2.1`) and **Summary** (the release page's introductory paragraph; where a release has none, the components it lists, e.g. `DataIntegration v26.2.0, Explore v26.2.2, …`) | 22 releases, 187 → about 3 pages | -3. **`Load Balancer` icon.** The T7 work added `overrides/.icons/other/load-balancer.svg` - (committed, tracked) but the two `mkdocs.yml` lines that reference it are **not** in - `HEAD` — line 63 still reads `# "Load Balancer": simple-awselasticloadbalancing`. The - icon file is currently dead weight and the tag renders bare. Probably an accidental - partial revert. Restore those two lines, or drop the SVG? **Recommend: restore**, it - was verified working. -> decision: restore it, verify it works (again) - -4. **Failure mode.** If a marker is present but the script cannot render it, should - `task build` fail, or warn and leave the marker? **Recommend: fail**, consistent with - `localize_bundle_assets.py`, since a silently empty listing is the exact failure this - whole guard-rail effort exists to prevent. -> decision: fail - -5. **`/tags/` page size.** 45 sections listing 531 page references. Acceptable, or should - the bare marker exclude high-cardinality tags like `TransformOperator`? **Recommend: - include everything**, matching production. -> decision: match prod, include everything - -## 6. Known limitation: `task serve` - -`zensical serve` rebuilds into `site/` on every change, so it would overwrite anything a -post-build step writes. The live-reload preview therefore shows the raw -`` markers, not the listings - measured: 0 tag sections, 3 markers -left on `/tutorials/`. The same applies to the glightbox localisation. - -`task preview` covers the gap: it runs a full `task build` and serves the result on port -8001. Verified: 45 sections on `/tags/`, 3 on `/tutorials/`, 0 markers, 0 unpkg references. -This is inherent to post-build processing and goes away with the script. - -## 7. Removal - -The trigger is already wired. `check_zensical_output.py` tracks `tag-listings` as `PEND` -and prints a `NEW` banner the moment Zensical renders a listing itself. On that signal: - -1. delete `tools/render_tag_listings.py` -2. drop its line from `build` in `Taskfile.yml` -3. delete `overrides/partials/tags.html` if §9 was implemented - Zensical will populate - `tag.url` itself once it has a listing index, and its stock template already handles it -4. drop the `tag-listings-*` checks from `check_zensical_output.py` - -Sources need no changes, because they were never changed. +In `list` mode, links from the kept tables to dropped pages print as plain text, not as footnotes; the +section opens with a sentence naming the online reference URL. -## 8. Acceptance +The configuration lives in `tools/pdf/print.yml`, keyed by the section's path in `nav.yml` +(`build/reference/`, `release-notes/`), so a section can be switched without touching code. -- `/tags/` renders 45 tag sections; `/tutorials/` renders 3, in marker order -- zero `` comments remain in `site/**/*.html` -- `check_zensical_output.py` no longer reports unexpanded markers -- every generated link resolves to a real file in `site/` -- tag chips in listings render with icons for the 31 mapped tags -- `task build` still clean under `--strict`; page count unchanged at 584 -- output stable across two consecutive builds (no ordering nondeterminism) +### Estimate for the default configuration -## 9. Link page tag chips to their listing anchor +| Step | Pages | +| :-- | --: | +| today | 1680 | +| A.3 as `list` | ≈ 1239 | +| H Release Notes as `list` | ≈ 1055 | +| tighter spacing (−6.5 %) | ≈ 985 | +| imprint, recto starts and blank pages (at most 28) | ≈ 1015 | -**Status:** implemented 2026-08-23 as `overrides/partials/tags.html`. All three open -questions settled as recommended; the reachability assertion is `tag-chips-resolve` in -`check_zensical_output.py`. +About 1,015 pages: within BoD's 1,200 for A4 on 80 g. + +## 6. Further changes towards a print-ready book block -### Problem +### Pagination -Rendering the listings only solved half the feature. Material also turns each per-page tag -chip into a link to its section on `/tags/`, so a reader can jump from "this page is tagged -Docker" to "everything tagged Docker". Zensical emits the chips as inert ``s. +- Parts start on a recto page: cover recto, then contents recto, then the part's text recto + (`pagebreak(to: "odd")`). Costs at most three blank pages per part, 27 in total. +- The front contents start on page 3, recto. +- Blank pages stay empty - no header, no footer, no page number. +- The final page count is even. -Measured on the current build: **531 pages carry a tag nav, 703 chips in total, 0 of them -links.** Production, for the same page: +### Typography + +- Hyphenation on for the print edition (`lang: "en"`); justified text without it opens wide gaps. +- Widow and orphan costs, so no single line is left at the top or bottom of a page. +- Minimum type size: code 7.7 pt and the 6.3 pt terminal tables are acceptable on A4; set a floor in + `codeblock()` so a wider table cannot shrink below 6 pt. +- Hairlines: table rules are 0.3 pt, dividers 0.5 pt - above the usual 0.25 pt minimum. +- Tighter paragraph and block spacing (§5). + +### Black and white -```html -Configuration -``` +- **Grey areas at least 20 % black** (BoD). Today's grounds are far lighter: the code and note ground + `ec-wash` (`#F3F5F6`) and the peach bands print as a few percent grey. Either raise them to 20 % or + drop the fills in favour of rules. +- Orange footer text, the link blue and the admonition accents need values that stay distinguishable + in grey; the orange running title in particular turns into a pale grey. +- The part diagrams mark their part with a red frame; in grey the frame stays visible but no longer + stands out. Check the four diagrams. +- Screenshots print in grey; UI states that differ only by colour lose their meaning. -versus ours: +### Images -```html -Configuration -``` +- In the print scope 331 of 586 images are below 300 ppi at their printed size, 49 below 150. +- Stop scaling small screenshots up to the text width, which lowers their resolution further - the + cheapest way to bring images closer to 300 ppi. +- 565 images carry transparency, almost all of them PNG screenshots with an alpha channel over a white + page. BoD wants transparency reduced before delivery, and PDF/X-4 does not remove it (§7). Composite + the alpha channel onto white while normalizing images, before Typst embeds them - lossless for these + images, and the book block then carries no transparency at all. -Now that §1-§8 generate those anchors, every one of these links has a valid destination. -The anchors exist; nothing points at them. +### PDF output -### Approach: template override, *not* post-build injection +- The optional normalization pass (§7). +- A preflight report per build: page size, page count against 1,200 and parity, all fonts embedded + (`pdffonts`), images below 300 ppi (`pdfimages -list`), soft masks left, page-number position per page + (`pdftotext -bbox`), blank pages really blank. -`zensical/templates/partials/tags.html` is **byte-identical to Material 9.7.7's**. It -already contains the branch that produces a link: +### Content -```jinja -{% if tag.url %} - {{ tag.name }} -{% else %} - {{ tag.name }} -{% endif %} -``` +- Web-only phrasing: "click here", "Next chapter:" links, embedded videos printed as + `[iframe: ]`. Print them as a footnote, or as a QR code for the few videos. +- Tutorial step headings that number themselves print as `A.9.3 1 Install …` - a content cleanup. +- An index at the back from the front-matter tags (45 tags on 531 pages) would give a paper reader a + second way in. Optional. -The template is not the problem - `tag.url` is simply never populated, because Zensical has -no listing index to point at. So the fix is to compute the URL in an override rather than -depend on `tag.url`, exactly as `overrides/partials/tabs-item.html` does for tab icons. +### Delivery -This is strictly better than extending `render_tag_listings.py`: +- CI builds the book block as a second artifact next to the screen PDF. +- The cover (§9) needs the final page count and paper for its spine width, so it comes last. -| | template override | post-build injection | -| :-- | :-- | :-- | -| Works in `task serve` | **yes** | no (see §6) | -| Extra build step | none | another pass over 531 pages | -| Removal when #38 lands | delete one file | more code to unpick | +## 7. Optional normalization pass (Ghostscript) -### Verified feasible +An optional post-processing step on the print PDF: `task pdf:print` runs it when asked +(`--normalize` / `PDF_NORMALIZE=1`) and writes `…-print-x4.pdf` next to the unnormalized file. BoD +accepts either; the normalized file is what a provider preflight sees. -A spike produced output byte-identical to production's, then was reverted. MiniJinja -supports the three filters needed (`url`, `lower`, `replace`): +### What it does -```jinja -{% set anchor = "tags/" | url ~ "#tag:" ~ (tag.name | lower | replace(" ", "-")) %} -{{- tag.name -}} -``` +| Target | How | +| :-- | :-- | +| PDF/X-4 | `-dPDFX=4` with a prefix file derived from Ghostscript's `lib/PDFX_def.ps`, which writes `/GTS_PDFXVersion (PDF/X-4)`, `/Trapped /False` and the output intent | +| CMYK | `-sColorConversionStrategy=CMYK -sProcessColorModel=DeviceCMYK`; the output intent profile is ISO Coated v2 (FOGRA39), the space BoD assumes for CMYK | +| Images at 300 dpi | down: `-dDownsampleColorImages=true -dColorImageDownsampleType=/Bicubic -dColorImageResolution=300 -dColorImageDownsampleThreshold=1.0`, the same for grey images; lossless re-encoding with `/FlateEncode`. **Up: not possible in Ghostscript** (below) | +| No annotations | `-dPreserveAnnots=false` | -`| url` resolves `tags/` relative to the current page, so it survives mike's versioned -prefixes without special handling. +### Verified with Ghostscript 10.08.0 -### Open questions +Converted pages 3-40 of the current PDF on 2026-09-14: -1. **Slug duplication.** The template computes the slug in MiniJinja; `render_tag_listings. - tag_slug()` computes it in Python. They must agree or every link dangles. Accept the - duplication with a comment in both places, or have the renderer assert that each anchor - it emits is reachable? **Recommend: assert** - it is a cheap required check and the - failure mode is otherwise silent. +- the file carries `/GTS_PDFXVersion (PDF/X-4)` in the document info and `pdfxid:GTS_PDFXVersion='PDF/X-4'` + in XMP, one output intent, `/Trapped /False`, no annotations, all 154 fonts embedded +- all 7 images converted from ICC-based RGB to CMYK; the 610 ppi image came out at 300 ppi +- the 215 ppi image **stayed at 215 ppi: Ghostscript downsamples, it never upsamples** +- all 7 soft masks survived - PDF/X-4 keeps transparency +- the Build cover rendered from the CMYK file shows no visible colour shift against the RGB original at + screen resolution; the red frame, the orange and the peach band stay distinct +- 38 pages in 6 s: the whole book block takes a few minutes +- Ghostscript 10.08's documentation (`doc/src/VectorDevices.rst`) confirms `-dPDFX` values 1, 3 and 4 + (default 3) and PDF/X-4 support; the rendered manual page still says X-1 and X-3 only + +Traps found: + +- **A page with any annotation makes Ghostscript revert to normal PDF output**, with only a one-line + warning. The book has about 3,900 link annotations - drop them (`-dPreserveAnnots=false`) and fail the + task if the output lacks the PDF/X marker. +- `-dPDFX=4` alone writes **no** PDF/X marker and no output intent; the prefix file is required. +- `-sOutputICCProfile` with `-dPDFX` crashes the PDF interpreter (`/undefined in --runpdf--`) and leaves a + truncated file - supply the profile only through the prefix file, which reads it with + `--permit-file-read=`. +- Ghostscript ships a generic `default_cmyk.icc`, not FOGRA39. The ISO Coated v2 profile comes from the + ECI and has to be downloaded and vendored (with its licence) or fetched in CI. + +### Upsampling to 300 dpi + +Ghostscript cannot raise an image's resolution. Images below 300 ppi at their printed size have to be +resampled **before** Typst embeds them, in the build: + +- the printed width is known at build time for the common case: an image wider than the text column + prints at the text width (16 cm), a narrower one at its natural size +- the build writes a resampled copy (Lanczos) to `dist/pdf/images/` with enough pixels for 300 ppi at + that width, and points Typst at it; the originals under `site/` stay untouched +- the same step composites alpha onto white (§6), so resampling and transparency reduction happen once + +Upsampling adds no detail - BoD says so itself: an image does not get better by raising its resolution +in a graphics program. It gives the printer a 300 dpi image to rasterize instead of leaving the +interpolation to the press, and it makes the preflight report clean. The originals below 150 ppi still +need replacing (P14). + +### Black and white + +The book prints in black and white, but BoD converts RGB itself and the pass is specified as CMYK. A +greyscale variant (`-sColorConversionStrategy=Gray`, output intent from a grey profile) would show the +final tones on screen and give smaller files. Keep CMYK as specified; add greyscale as a preview mode of +the same task, for checking the palette (P11). -2. **Tags with no listing section.** A tag carried *only* by the listing page itself - produces no section (self-exclusion, §3), so its chip would link to a dead anchor. Does - not occur today. **Recommend: cover it with the check in Q1** rather than special-casing. +## 8. Acceptance -3. **`hide: tags` pages.** The existing template already honours it; the override must keep - that branch. No decision needed, just do not drop it. +- `task pdf` builds the screen PDF exactly as before: 1680 pages, unchanged look +- `task pdf:print` builds the book block with the configuration in `tools/pdf/print.yml` +- page 1 is the title page naming eccenca GmbH as publisher; page 2 is the imprint with the publisher's + address and the authors from `tools/pdf/authors.yml`, most commits first +- on every numbered page the page number sits at the outer edge: left on even pages, right on odd + pages - checked by the preflight report, not by eye +- title page, imprint, part covers and blank pages carry no header and no footer; text pages carry no + logo and no version stamp +- the front contents and every part cover start on an odd page +- A.3 and H Release Notes print as two-column lists in the default configuration; switching either to + `full` or `omit` in `print.yml` works without code changes +- internal links print page references, external links footnotes; the book block has no link annotations +- page count even and at most 1,200 +- grey areas at least 20 % black; all fonts embedded; no soft masks left +- every image at 300 ppi or more at its printed size, or listed and accepted +- with normalization: the file declares PDF/X-4, carries a FOGRA39 output intent, CMYK images only, no + image above 300 ppi +- `task check` and `task test:unit` pass + +## 9. Out of scope + +- the cover file: front, spine and back, spine width from page count and paper, barcode +- ISBN and retail distribution (D3, maybe later) +- EPUB or other e-book formats +- translations diff --git a/tests/test_build_pdf.py b/tests/test_build_pdf.py index 639731c53..b5229759f 100644 --- a/tests/test_build_pdf.py +++ b/tests/test_build_pdf.py @@ -24,6 +24,7 @@ def soup(markup: str) -> BeautifulSoup: def test_section_opens_with_its_index_page(): + # A page keeps its navigation title; an index page takes its section's. nav = [ {"Build": [ {"Build": "build/index.md"}, @@ -32,10 +33,10 @@ def test_section_opens_with_its_index_page(): ]}, ] assert nav_entries(nav) == [ - NavEntry(depth=0, md="build/index.md"), - NavEntry(depth=1, md="build/rules/index.md"), - NavEntry(depth=2, md="build/rules/linking.md"), - NavEntry(depth=1, md="build/spark.md"), + NavEntry(depth=0, md="build/index.md", title="Build"), + NavEntry(depth=1, md="build/rules/index.md", title="Rules"), + NavEntry(depth=2, md="build/rules/linking.md", title="Linking"), + NavEntry(depth=1, md="build/spark.md", title="Spark"), ] @@ -50,13 +51,13 @@ def test_section_without_index_page_gets_a_heading(): assert nav_entries(nav) == [ NavEntry(depth=0, title="Release Notes"), NavEntry(depth=1, title="2026"), - NavEntry(depth=2, md="release-notes/2026/corporate-memory-26-2/index.md"), + NavEntry(depth=2, md="release-notes/2026/corporate-memory-26-2/index.md", title="v26.2.1"), ] def test_page_listed_twice_appears_once_and_links_are_skipped(): nav = [{"A": ["a/index.md", {"Again": "a/index.md"}, {"Site": "https://example.org/"}]}] - assert nav_entries(nav) == [NavEntry(depth=0, md="a/index.md")] + assert nav_entries(nav) == [NavEntry(depth=0, md="a/index.md", title="A")] def test_merge_breaks_chapters_adds_section_headings_and_drops_web_chrome(tmp_path): diff --git a/tests/test_build_pdf_images.py b/tests/test_build_pdf_images.py new file mode 100644 index 000000000..07cd4cf38 --- /dev/null +++ b/tests/test_build_pdf_images.py @@ -0,0 +1,83 @@ +"""Test the print edition's image copies: on white, at 300 ppi at the printed width""" +from collections import Counter +from pathlib import Path + +from bs4 import BeautifulSoup +from PIL import Image + +from tools.build_pdf import print_image, resolve_images + + +def make(path: Path, size: tuple[int, int], dpi: tuple[int, int] | None = None) -> None: + image = Image.new("RGB", size, (0, 0, 255)) + image.save(path, **({"dpi": dpi} if dpi else {})) + + +def test_a_wide_screenshot_prints_at_the_text_width_with_300_ppi(tmp_path): + source = tmp_path / "wide.png" + make(source, (4000, 1000)) + with Image.open(print_image(source, None, tmp_path / "cache")) as copy: + # 16 cm are 453.54 pt, which at 300 ppi take 1890 pixels. + assert copy.size == (1890, 472) + assert round(copy.info["dpi"][0]) == 300 + + +def test_a_small_screenshot_keeps_its_printed_size_and_gains_pixels(tmp_path): + source = tmp_path / "small.png" + make(source, (300, 100), dpi=(144, 144)) + with Image.open(print_image(source, None, tmp_path / "cache")) as copy: + # At 144 dpi it prints 150 pt wide, which at 300 ppi take 625 pixels. + assert copy.size == (625, 208) + assert round(copy.info["dpi"][0]) == 300 + + +def test_a_percentage_width_is_that_share_of_the_text_width(tmp_path): + source = tmp_path / "half.png" + make(source, (2000, 1000)) + with Image.open(print_image(source, "50%", tmp_path / "cache")) as copy: + assert copy.width == 945 + + +def test_transparency_is_flattened_onto_white(tmp_path): + source = tmp_path / "alpha.png" + image = Image.new("RGBA", (200, 100), (0, 0, 0, 0)) + image.putpixel((0, 0), (255, 0, 0, 255)) + image.save(source) + with Image.open(print_image(source, None, tmp_path / "cache")) as copy: + assert copy.mode == "RGB" + assert copy.getpixel((copy.width - 1, copy.height - 1)) == (255, 255, 255) + + +def test_a_gif_becomes_a_png_of_its_first_frame(tmp_path): + source = tmp_path / "animation.gif" + frames = [Image.new("P", (100, 50), index) for index in (1, 2)] + frames[0].save(source, save_all=True, append_images=frames[1:]) + copy = print_image(source, None, tmp_path / "cache") + assert copy.suffix == ".png" + with Image.open(copy) as image: + assert image.mode == "RGB" + + +def test_the_copy_is_reused_while_the_image_and_its_width_stay_the_same(tmp_path): + source = tmp_path / "shot.png" + make(source, (900, 300)) + first = print_image(source, None, tmp_path / "cache") + written = first.stat().st_mtime_ns + assert print_image(source, None, tmp_path / "cache") == first + assert first.stat().st_mtime_ns == written + assert print_image(source, "50%", tmp_path / "cache") != first + + +def test_print_images_point_at_their_copies_and_vector_images_stay(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + site = Path("site") + (site / "build").mkdir(parents=True) + make(site / "build" / "shot.png", (900, 300)) + (site / "build" / "chart.svg").write_text("") + doc = BeautifulSoup('ShotChart', "html.parser") + stats: Counter = Counter() + resolve_images(doc, site, stats, [], Path("dist/pdf/print/images")) + shot, chart = doc.find_all("img") + assert shot["src"].startswith("/dist/pdf/print/images/") and shot["src"].endswith(".png") + assert chart["src"] == "/site/build/chart.svg" + assert stats["images normalized for print"] == 1 diff --git a/tests/test_build_pdf_print.py b/tests/test_build_pdf_print.py new file mode 100644 index 000000000..018b1031c --- /dev/null +++ b/tests/test_build_pdf_print.py @@ -0,0 +1,248 @@ +"""Test the print edition's section modes: full, list and omit""" +import click +import pytest + +from tools.build_pdf import ( + Generated, + NavEntry, + SectionRule, + apply_section_rules, + load_section_rules, + md_to_built_html, + merge_pages, +) + +REFERENCE = [ + NavEntry(0, "build/index.md"), + NavEntry(1, "build/reference/index.md"), + NavEntry(2, "build/reference/aggregator/index.md"), + NavEntry(3, "build/reference/aggregator/average.md"), + NavEntry(2, "build/reference/transformer/index.md"), + NavEntry(3, "build/reference/transformer/Numeric/index.md"), + NavEntry(4, "build/reference/transformer/Numeric/add.md"), + NavEntry(1, "build/spark.md"), +] + +# The shape of Release Notes in nav.yml: no page stands for the section or its years. +RELEASES = [ + NavEntry(0, title="Release Notes"), + NavEntry(1, title="2026"), + NavEntry(2, "release-notes/2026/cm-26-2/index.md"), + NavEntry(2, "release-notes/2026/cm-26-1/index.md"), + NavEntry(1, title="2025"), + NavEntry(2, "release-notes/2025/cm-25-3/index.md"), + NavEntry(0, "tutorials/index.md"), +] + +# The shape of Transformers in nav.yml: the overview page lists every operator, +# and the categories are titles without a page. +CATEGORIES = [ + NavEntry(1, "build/reference/index.md", "Task and Operator Reference"), + NavEntry(2, "build/reference/transformer/index.md", "Transformers"), + NavEntry(3, title="Combine"), + NavEntry(4, "build/reference/transformer/Combine/concat.md", "Concatenate"), + NavEntry(4, "build/reference/transformer/Combine/zip.md", "Zip"), + NavEntry(3, title="Date"), + NavEntry(4, "build/reference/transformer/Date/duration.md"), + NavEntry(1, "build/spark.md", "Spark"), +] +CATEGORY_TABLE = Generated( + "groups", "build/reference/", "list", columns=("Category", "Transformers"), + groups=[("Combine", [CATEGORIES[3], CATEGORIES[4]]), ("Date", [CATEGORIES[6]])], +) + + +def test_rules_read_a_mode_or_a_mapping_with_columns(tmp_path): + path = tmp_path / "print.yml" + path.write_text( + "sections:\n" + " build/reference/: list\n" + " release-notes:\n" + " mode: list\n" + " columns: [Release, Summary]\n" + " tutorials/: full\n" + ) + assert load_section_rules(path) == [ + SectionRule("build/reference/", "list"), + SectionRule("release-notes/", "list", ("Release", "Summary")), + SectionRule("tutorials/", "full"), + ] + + +def test_an_unknown_mode_fails(tmp_path): + path = tmp_path / "print.yml" + path.write_text("sections:\n build/: shorten\n") + with pytest.raises(click.ClickException, match="shorten"): + load_section_rules(path) + + +def test_list_keeps_the_overview_pages_and_drops_what_they_list(): + entries, dropped = apply_section_rules(REFERENCE, [SectionRule("build/reference/", "list")]) + assert entries == [ + NavEntry(0, "build/index.md"), + NavEntry(1, "build/reference/index.md"), + NavEntry(1, generated=Generated("note", "build/reference/", "list", ["build/reference/index.md"])), + NavEntry(2, "build/reference/aggregator/index.md"), + NavEntry(2, "build/reference/transformer/index.md"), + NavEntry(1, "build/spark.md"), + ] + assert dropped == { + "build/reference/aggregator/average.md", + "build/reference/transformer/Numeric/index.md", + "build/reference/transformer/Numeric/add.md", + } + + +def test_list_without_overview_pages_makes_a_table_under_each_heading(): + rule = SectionRule("release-notes/", "list", ("Release", "Summary")) + entries, dropped = apply_section_rules(RELEASES, [rule]) + first = "release-notes/2026/cm-26-2/index.md" + assert entries == [ + NavEntry(0, title="Release Notes"), + NavEntry(1, generated=Generated("note", "release-notes/", "list", [first], name="Release Notes")), + NavEntry(1, title="2026"), + NavEntry(2, generated=Generated( + "table", "release-notes/", "list", [first, "release-notes/2026/cm-26-1/index.md"], ("Release", "Summary") + )), + NavEntry(1, title="2025"), + NavEntry(2, generated=Generated( + "table", "release-notes/", "list", ["release-notes/2025/cm-25-3/index.md"], ("Release", "Summary") + )), + NavEntry(0, "tutorials/index.md"), + ] + assert len(dropped) == 3 + + +def test_list_turns_the_titles_an_overview_empties_into_one_table(): + entries, dropped = apply_section_rules(CATEGORIES, [SectionRule("build/reference/", "list")]) + assert entries == [ + CATEGORIES[0], + NavEntry(1, generated=Generated("note", "build/reference/", "list", ["build/reference/index.md"])), + CATEGORIES[1], + NavEntry(3, generated=CATEGORY_TABLE), + CATEGORIES[7], + ] + assert dropped == {CATEGORIES[3].md, CATEGORIES[4].md, CATEGORIES[6].md} + + +def test_omit_drops_the_pages_and_leaves_a_heading_with_a_note(): + entries, dropped = apply_section_rules(REFERENCE, [SectionRule("build/reference/", "omit")]) + assert entries == [ + NavEntry(0, "build/index.md"), + NavEntry(1, generated=Generated("note", "build/reference/", "omit", ["build/reference/index.md"])), + NavEntry(1, "build/spark.md"), + ] + assert len(dropped) == 6 + + +def test_omitting_a_whole_part_without_a_page_leaves_nothing_of_it(): + entries, dropped = apply_section_rules(RELEASES, [SectionRule("release-notes/", "omit")]) + assert entries == [NavEntry(0, "tutorials/index.md")] + assert len(dropped) == 3 + + +def test_full_changes_nothing(): + assert apply_section_rules(RELEASES, [SectionRule("release-notes/", "full")]) == (RELEASES, set()) + + +def test_a_section_without_pages_in_the_navigation_fails(): + with pytest.raises(click.ClickException, match="build/nothing/"): + apply_section_rules(REFERENCE, [SectionRule("build/nothing/", "list")]) + + +def write_page(site, md, body): + path = site / md_to_built_html(md) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'
{body}
') + + +def test_merge_renders_notes_and_tables_and_unlinks_what_the_section_drops(tmp_path): + site = tmp_path / "site" + write_page(site, "build/reference/index.md", '

Task and Operator Reference

All operators.

') + write_page( + site, "build/reference/aggregator/index.md", + '

Aggregators

Average
' + '

See Spark.

', + ) + write_page( + site, "release-notes/2026/cm-26-2/index.md", + '

Corporate Memory 26.2.1

The second major release.

' + '

DataIntegration v26.2.0

', + ) + write_page( + site, "release-notes/2026/cm-26-1/index.md", + '

Corporate Memory 26.1.3

no paragraph
' + '

DataIntegration v26.1.0

Explore v26.1.3

', + ) + first, second = "release-notes/2026/cm-26-2/index.md", "release-notes/2026/cm-26-1/index.md" + entries = [ + NavEntry(1, "build/reference/index.md"), + NavEntry(1, generated=Generated("note", "build/reference/", "list", ["build/reference/index.md"])), + NavEntry(2, "build/reference/aggregator/index.md"), + NavEntry(0, title="Release Notes"), + NavEntry(1, generated=Generated("note", "release-notes/", "list", [first], name="Release Notes")), + NavEntry(1, title="2026"), + NavEntry(2, generated=Generated("table", "release-notes/", "list", [first, second], ("Release", "Summary"))), + ] + rules = [SectionRule("build/reference/", "list"), SectionRule("release-notes/", "list", ("Release", "Summary"))] + dropped = {"build/reference/aggregator/average.md", first, second} + doc, missing = merge_pages(entries, site, "https://example.org/26.2/", rules, dropped) + assert missing == [] + html = str(doc.body) + text = " ".join(doc.body.get_text(" ").split()) + + # The overview's link to a dropped operator page prints as its text; a link + # to a page outside the print edition still leads to the published site. + assert "Average" in html + assert 'href="https://example.org/26.2/build/spark/"' in html + + # A note names the section and where it is complete: the section's own URL + # when it has a page, else the first page it lists. + assert ( + "This print edition lists the Task and Operator Reference in short. The complete section is part of " + "the online edition: https://example.org/26.2/build/reference/" + ) in text + assert ( + "This print edition lists the Release Notes in short. The complete section is part of the online " + "edition: https://example.org/26.2/release-notes/2026/cm-26-2/" + ) in text + + # The table: each page's title and first paragraph, without links; a page + # without a paragraph is summarized by its headings. + table = doc.find("table", class_="print-list") + rows = [[cell.get_text(" ", strip=True) for cell in row.find_all(["th", "td"])] for row in table.find_all("tr")] + assert rows == [ + ["Release", "Summary"], + ["Corporate Memory 26.2.1", "The second major release."], + ["Corporate Memory 26.1.3", "DataIntegration v26.1.0, Explore v26.1.3"], + ] + assert table.find("a") is None + + +def test_merge_renders_the_emptied_titles_as_one_table_of_their_pages(tmp_path): + site = tmp_path / "site" + write_page(site, "build/reference/transformer/Date/duration.md", '

Duration

') + doc, _ = merge_pages([NavEntry(3, generated=CATEGORY_TABLE)], site, "https://example.org/26.2/") + table = doc.find("table", class_="print-list") + rows = [[cell.get_text(" ", strip=True) for cell in row.find_all(["th", "td"])] for row in table.find_all("tr")] + # A page without a navigation title is named by its own title. + assert rows == [ + ["Category", "Transformers"], + ["Combine", "Concatenate, Zip"], + ["Date", "Duration"], + ] + assert doc.body.find(["h1", "h2", "h3", "h4", "h5", "h6"]) is None + # Without an alignment pandoc would centre the cells. + assert {cell.get("style") for cell in table.find_all(["th", "td"])} == {"text-align: left;"} + + +def test_merge_renders_an_omitted_section_as_its_title_and_a_note(tmp_path): + site = tmp_path / "site" + write_page(site, "build/reference/index.md", '

Task and Operator Reference

') + entries = [NavEntry(1, generated=Generated("note", "build/reference/", "omit", ["build/reference/index.md"]))] + doc, _ = merge_pages(entries, site, "https://example.org/26.2/", [SectionRule("build/reference/", "omit")], set()) + assert doc.body.find("h2").get_text() == "Task and Operator Reference" + assert ( + "The Task and Operator Reference is not part of this print edition. It is part of the online edition: " + "https://example.org/26.2/build/reference/" + ) in " ".join(doc.body.get_text(" ").split()) diff --git a/tests/test_pdf_authors.py b/tests/test_pdf_authors.py new file mode 100644 index 000000000..ec5046811 --- /dev/null +++ b/tests/test_pdf_authors.py @@ -0,0 +1,45 @@ +"""Test the author list of the print edition's imprint""" +import yaml + +from tools.pdf_authors import authors_yaml, next_page, select_authors + + +def test_most_commits_first_and_ties_by_id_case_insensitive(): + contributors = [ + {"login": "haschek", "contributions": 10, "type": "User"}, + {"login": "seebi", "contributions": 532, "type": "User"}, + {"login": "BorderCloud", "contributions": 10, "type": "User"}, + {"login": "rpietzsch", "contributions": 639, "type": "User"}, + ] + assert [a["id"] for a in select_authors(contributors)] == ["rpietzsch", "seebi", "BorderCloud", "haschek"] + + +def test_bots_agents_and_anonymous_contributions_are_not_authors(): + contributors = [ + {"login": "dependabot[bot]", "contributions": 40, "type": "Bot"}, + {"login": "claude-agent", "contributions": 30, "type": "User"}, + {"login": "Codex", "contributions": 20, "type": "User"}, + {"name": "Someone", "email": "someone@example.org", "contributions": 33, "type": "Anonymous"}, + {"login": "sobo", "contributions": 53, "type": "User"}, + ] + assert select_authors(contributors) == [{"id": "sobo", "commits": 53}] + + +def test_pagination_follows_the_next_link_only(): + header = ( + '; rel="next", ' + '; rel="last"' + ) + assert next_page(header) == "https://api.github.com/repositories/1/contributors?per_page=100&page=2" + assert next_page("") is None + + +def test_the_file_names_its_source_and_keeps_the_order(): + text = authors_yaml("eccenca/docs", [{"id": "b", "commits": 2}, {"id": "a", "commits": 1}]) + assert text.startswith("---\n# Generated by `dec-tool pdf-authors`") + # yamllint's default rules want list items indented under their key. + assert "authors:\n - id: b\n commits: 2\n" in text + assert yaml.safe_load(text) == { + "repository": "eccenca/docs", + "authors": [{"id": "b", "commits": 2}, {"id": "a", "commits": 1}], + } diff --git a/tests/test_update_di_reference.py b/tests/test_update_di_reference.py index 6328c512a..0ee2cdcfa 100644 --- a/tests/test_update_di_reference.py +++ b/tests/test_update_di_reference.py @@ -29,13 +29,13 @@ def test_get_plugin_descriptions(): assert titles == sorted(titles) -def _make_plugin(plugin_id, plugin_type="transformer", main_category="Extract", related=None): +def _make_plugin(plugin_id, plugin_type="transformer", main_category="Extract", related=None, description="test plugin"): """Build a minimal valid PluginDescription for a test, filling in only what varies.""" return PluginDescription( pluginId=plugin_id, title=plugin_id, categories=[main_category], - description="test plugin", + description=description, properties={}, actions={}, required=[], @@ -45,6 +45,31 @@ def _make_plugin(plugin_id, plugin_type="transformer", main_category="Extract", ) +@pytest.mark.parametrize( + "description, expected", + [ + # the Excel map transformer: a fence without a language + ( + "sheets of the form:\n```\nmapFrom,mapTo\n,\n```\nand more.", + "sheets of the form: `mapFrom,mapTo ,` and more.", + ), + # a fence with a language + ("Run:\n```sparql\nSELECT *\nWHERE { ?s ?p ?o }\n```", "Run: `SELECT * WHERE { ?s ?p ?o }`"), + # a fence already on one line + ("Use ```a,b``` here.", "Use `a,b` here."), + # a backtick inside needs a longer delimiter + ("Quote:\n```\nx = `y`\n```", "Quote: `` x = `y` ``"), + # inline code stays as it is + ("Insert `=\"\"` in a cell.", "Insert `=\"\"` in a cell."), + ], +) +def test_description_turns_a_fenced_block_into_a_code_span(description, expected): + # A fence squeezed onto one line is no fence in Markdown, but rumdl --fix + # still takes it for one and inserts a blank line before it - which ends the + # operator table the description is a row of. + assert _make_plugin("excelMap", description=description).description == expected + + @pytest.mark.parametrize( "current_path, target_path, expected", [ diff --git a/tools/__init__.py b/tools/__init__.py index 3e42a61e4..b38c1f2a2 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -6,6 +6,7 @@ from tools.build_pdf import build_pdf from tools.check_zensical_output import check_zensical_output from tools.localize_bundle_assets import localize_bundle_assets +from tools.pdf_authors import pdf_authors from tools.publish import publish from tools.update_di_reference import update_di_reference from tools.update_icons import update_icons @@ -19,6 +20,7 @@ def cli(): cli.add_command(build_pdf) cli.add_command(check_zensical_output) cli.add_command(localize_bundle_assets) +cli.add_command(pdf_authors) cli.add_command(publish) cli.add_command(update_icons) cli.add_command(update_di_reference) diff --git a/tools/build_pdf.py b/tools/build_pdf.py index 197858b3a..2d2ba7619 100644 --- a/tools/build_pdf.py +++ b/tools/build_pdf.py @@ -8,12 +8,20 @@ converted to Typst by pandoc with `tools/pdf/filter.lua`, and typeset by Typst with the eccenca house style in `tools/pdf/style.typ`. -Invoked by `task pdf`. Every option falls back to an environment variable, so -BUILD_VERSION, PDF_OUT, PANDOC and TYPST keep working as tunables. +`--edition print` builds the book block of a printed book instead of the screen +PDF: the same pipeline, shortened by the section modes in `tools/pdf/print.yml`, +with images copied at 300 ppi without transparency, and typeset by the print +branches of the style (tasks/spec.md). + +Invoked by `task pdf` and `task pdf:print`. Every option falls back to an +environment variable, so BUILD_VERSION, PDF_EDITION, PDF_OUT, PANDOC and TYPST +keep working as tunables. """ from __future__ import annotations import base64 +import hashlib +import io import re import shutil import subprocess @@ -21,13 +29,14 @@ import time import urllib.parse from collections import Counter -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date from pathlib import Path import click import yaml from bs4 import BeautifulSoup, NavigableString +from PIL import Image DEFAULT_OUT_STEM = "dist/documentation-eccenca-com" SITE_DIR = Path("site") @@ -35,6 +44,8 @@ MKDOCS_YML = Path("mkdocs.yml") # Style, pandoc template, filter, logo and vendored fonts. PDF_ASSETS = Path("tools/pdf") +# Publisher and section modes of the print edition. +PRINT_YML = PDF_ASSETS / "print.yml" # The merged HTML and the Typst source pandoc writes, kept for debugging. WORK_DIR = Path("dist/pdf") CONTENT_SELECTOR = "article.md-content__inner" @@ -56,15 +67,61 @@ TESTED_TYPST = "0.15" BOOK_TITLE = "eccenca Corporate Memory" BOOK_CONTEXT = "Documentation" +# The screen PDF, and the book block of the printed book (tasks/spec.md). +EDITIONS = ("screen", "print") +# How the print edition prints a navigation section (tools/pdf/print.yml). +SECTION_MODES = ("full", "list", "omit") +DEFAULT_COLUMNS = ("Page", "Summary") +# A `groups` table: the navigation titles under an overview page - the operator +# categories of the reference - and their pages, under the overview's title. +GROUP_COLUMNS = ("Category", "Pages") +# The print edition's text column is 16 cm wide, and BoD asks for images at +# 300 dpi at their printed size (tasks/spec.md, §2). +TEXT_WIDTH_PT = 16 / 2.54 * 72 +PRINT_PPI = 300 + + +@dataclass +class Generated: + """Content the print edition sets in place of a section's pages. + + A `note` names the online edition for a section in `list` or `omit` mode; a + `table` lists pages that no overview page of the section lists; `groups` + lists the navigation titles whose pages an overview page lists, each with + the names of its pages. + """ + + kind: str + section: str + mode: str + pages: list[str] = field(default_factory=list) + columns: tuple[str, str] = DEFAULT_COLUMNS + # The section's name when no page of it carries one. + name: str | None = None + # The rows of a `groups` table: a navigation title and the page entries beneath it. + groups: list[tuple[str, list[NavEntry]]] = field(default_factory=list) @dataclass class NavEntry: - """One stop along the navigation: a page, or the title of a section that has none.""" + """One stop along the navigation: a page, the title of a section that has none, or generated content. + + A page carries the title the navigation gives it, when it gives one. + """ depth: int md: str | None = None title: str | None = None + generated: Generated | None = None + + +@dataclass +class SectionRule: + """How the print edition prints the pages under one docs/ directory.""" + + prefix: str + mode: str + columns: tuple[str, str] = DEFAULT_COLUMNS def load_site_config() -> dict: @@ -106,14 +163,16 @@ def nav_entries(nav: list) -> list[NavEntry]: depth. A section without one - Release Notes and its years - gets its title as a heading of its own; otherwise its pages would read as part of whichever chapter came before. Everything else in a section sits one level below it. + A page keeps the title the navigation gives it; an index page takes its + section's. """ entries: list[NavEntry] = [] seen: set[str] = set() - def add_page(md: str, depth: int) -> None: + def add_page(md: str, depth: int, title: str | None = None) -> None: if md.endswith(".md") and md not in seen: seen.add(md) - entries.append(NavEntry(depth=depth, md=md)) + entries.append(NavEntry(depth=depth, md=md, title=title)) def add_section(title: str, children: list, depth: int) -> None: index = section_index(children[0]) if children else None @@ -121,7 +180,7 @@ def add_section(title: str, children: list, depth: int) -> None: entries.append(NavEntry(depth=depth, title=str(title))) add_items(children, depth + 1) else: - add_page(index, depth) + add_page(index, depth, str(title)) add_items(children[1:], depth + 1) def add_items(items: list, depth: int) -> None: @@ -131,7 +190,7 @@ def add_items(items: list, depth: int) -> None: elif isinstance(item, dict): for title, value in item.items(): if isinstance(value, str): - add_page(value, depth) + add_page(value, depth, str(title)) elif isinstance(value, list): add_section(title, value, depth) @@ -145,6 +204,193 @@ def load_nav_entries(nav_yml: Path = NAV_YML) -> list[NavEntry]: return nav_entries(data.get("nav") or []) +# -- print edition: section modes ------------------------------------------------ +# The print edition fits the page limit of a printed book by shortening sections +# that are reference material online (tools/pdf/print.yml). `list` keeps a +# section's overview pages and drops the pages they list; pages no overview +# lists become a table of title and first paragraph, and navigation titles left +# without pages become one table of titles and page names. `omit` drops a +# section and leaves a note naming the online edition. + + +def load_section_rules(path: Path = PRINT_YML) -> list[SectionRule]: + """The section modes of the print edition. + + A mode is given either as the value of a section, or as `mode` in a mapping + that may also name the two `columns` of the section's tables. + """ + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + rules = [] + for prefix, value in (data.get("sections") or {}).items(): + spec = value if isinstance(value, dict) else {"mode": value} + mode = spec.get("mode", "full") + if mode not in SECTION_MODES: + raise click.ClickException( + f"{path}: section {prefix} has mode {mode!r}, not one of {', '.join(SECTION_MODES)}" + ) + columns = tuple(spec.get("columns") or DEFAULT_COLUMNS) + if len(columns) != 2: + raise click.ClickException(f"{path}: section {prefix} needs two columns, not {len(columns)}") + rules.append(SectionRule(str(prefix).rstrip("/") + "/", mode, columns)) + return rules + + +def is_heading_entry(entry: NavEntry) -> bool: + """A section title the navigation gives without a page.""" + return entry.md is None and entry.generated is None + + +def section_name(prefix: str) -> str: + """A readable name for a section that has neither a page nor a navigation title.""" + return prefix.rstrip("/").rsplit("/", 1)[-1].replace("-", " ").replace("_", " ").title() + + +def list_section(entries: list[NavEntry], rule: SectionRule) -> tuple[list[NavEntry], set[str]]: + """Keep a section's own page and its subsections' overview pages; table what no overview lists. + + The navigation titles left without pages are tabled too (`group_listed_titles`). + """ + root = rule.prefix + "index.md" + overview = re.compile(re.escape(rule.prefix) + r"[^/]+/index\.md$") + kept = {e.md for e in entries if e.md and (e.md == root or overview.match(e.md))} + # An overview page lists the pages in and below its own directory. + listed_by = [md[: -len("index.md")] for md in kept] + result: list[NavEntry] = [] + dropped: set[str] = set() + listed: set[str] = set() + table: Generated | None = None + for entry in entries: + if not (entry.md and entry.md.startswith(rule.prefix)): + result.append(entry) + table = None + continue + if entry.md in kept: + result.append(entry) + table = None + if entry.md == root: + result.append(NavEntry(entry.depth, generated=Generated("note", rule.prefix, "list", [root]))) + continue + dropped.add(entry.md) + if any(entry.md.startswith(directory) for directory in listed_by): + listed.add(entry.md) + continue + if table is None: + table = Generated("table", rule.prefix, "list", [], rule.columns) + result.append(NavEntry(entry.depth, generated=table)) + table.pages.append(entry.md) + result = group_listed_titles(entries, result, listed, rule) + + if root not in kept: + first = next((i for i, e in enumerate(result) if e.generated and e.generated.section == rule.prefix), None) + if first is not None: + # The note opens the section: before the titles that lead into its + # first table, after the part title they belong to. + start = first + while start > 0 and is_heading_entry(result[start - 1]) and result[start - 1].depth > 0: + start -= 1 + before = result[start - 1] if start > 0 else None + name = before.title if before is not None and is_heading_entry(before) else section_name(rule.prefix) + pages = result[first].generated.pages[:1] + note = Generated("note", rule.prefix, "list", pages, name=name) + result.insert(start, NavEntry(result[start].depth, generated=note)) + return result, dropped + + +def group_listed_titles( + entries: list[NavEntry], result: list[NavEntry], listed: set[str], rule: SectionRule +) -> list[NavEntry]: + """Replace each run of navigation titles whose pages an overview lists by one table. + + Without their pages such titles would print as empty headings - the + operator categories after the Transformers overview do. The table has a row + per title naming the pages beneath it; the overview before the run names the + column of pages. Titles are told apart by identity, as two can read the same. + """ + beneath: dict[int, list[NavEntry]] = {} + for i, entry in enumerate(entries): + if is_heading_entry(entry): + pages: list[NavEntry] = [] + for below in entries[i + 1:]: + if below.depth <= entry.depth: + break + if below.md: + pages.append(below) + beneath[id(entry)] = pages + + def emptied(index: int) -> bool: + entry = result[index] + pages = beneath.get(id(entry), []) if is_heading_entry(entry) else [] + following = result[index + 1] if index + 1 < len(result) else None + return ( + bool(pages) + and all(page.md in listed for page in pages) + and (following is None or following.depth <= entry.depth) + ) + + grouped: list[NavEntry] = [] + i = 0 + while i < len(result): + if not emptied(i): + grouped.append(result[i]) + i += 1 + continue + depth = result[i].depth + rows = [] + while i < len(result) and result[i].depth == depth and emptied(i): + rows.append((result[i].title, beneath[id(result[i])])) + i += 1 + overview = next((e for e in reversed(grouped) if e.md and e.depth < depth), None) + pages_column = overview.title if overview is not None and overview.title else GROUP_COLUMNS[1] + table = Generated("groups", rule.prefix, "list", columns=(GROUP_COLUMNS[0], pages_column), groups=rows) + grouped.append(NavEntry(depth, generated=table)) + return grouped + + +def drop_empty_headings(entries: list[NavEntry]) -> list[NavEntry]: + """Remove section titles that no longer have anything beneath them.""" + result: list[NavEntry] = [] + for entry in reversed(entries): + following = result[-1] if result else None + if is_heading_entry(entry) and (following is None or following.depth <= entry.depth): + continue + result.append(entry) + return list(reversed(result)) + + +def omit_section(entries: list[NavEntry], rule: SectionRule) -> tuple[list[NavEntry], set[str]]: + """Drop a section. A section with a page of its own leaves its title and a note in its place.""" + root = rule.prefix + "index.md" + result: list[NavEntry] = [] + dropped: set[str] = set() + for entry in entries: + if entry.md and entry.md.startswith(rule.prefix): + dropped.add(entry.md) + if entry.md == root: + result.append(NavEntry(entry.depth, generated=Generated("note", rule.prefix, "omit", [root]))) + continue + result.append(entry) + return drop_empty_headings(result), dropped + + +def apply_section_rules(entries: list[NavEntry], rules: list[SectionRule]) -> tuple[list[NavEntry], set[str]]: + """Shorten the navigation by the section modes. Returns the entries and the pages dropped from them.""" + dropped: set[str] = set() + for rule in rules: + if not any(e.md and e.md.startswith(rule.prefix) for e in entries): + raise click.ClickException(f"{PRINT_YML}: no page in {NAV_YML} lies under {rule.prefix}") + if rule.mode == "list": + entries, gone = list_section(entries, rule) + elif rule.mode == "omit": + entries, gone = omit_section(entries, rule) + else: + continue + dropped |= gone + return entries, dropped + + +# -- merging --------------------------------------------------------------------- + + def md_to_built_html(src_md: str) -> str: """Map a docs/ markdown path to its built HTML (use_directory_urls=True).""" if src_md == "index.md": @@ -200,13 +446,17 @@ def namespace_ids(section, section_id: str) -> None: a["href"] = f"#{section_id}-{a['href'][1:]}" -def resolve_links(section, base_url: str, valid_ids: set, public_base: str) -> None: +def resolve_links( + section, base_url: str, valid_ids: set, public_base: str, unlink_ids: set | frozenset = frozenset() +) -> None: """Turn page-relative URLs into something valid inside the merged document. - Links to other documented pages become in-PDF anchor jumps. Everything - else relative - downloadable resources, screenshots opened at full size, - pages outside the navigation - becomes an absolute link into the published - site, which is the only address a reader of the PDF can follow. + Links to other documented pages become in-PDF anchor jumps. Links to a page + in `unlink_ids` - one the print edition drops from the section being merged + - print as their text. Everything else relative - downloadable resources, + screenshots opened at full size, pages outside the navigation - becomes an + absolute link into the published site, which is the only address a reader of + the PDF can follow. Image sources become root-absolute site paths; they are embedded, not linked. """ @@ -227,6 +477,8 @@ def resolve_links(section, base_url: str, valid_ids: set, public_base: str) -> N if parsed.fragment else f"#{section_id}" ) + elif section_id and section_id in unlink_ids: + a.unwrap() else: a["href"] = urllib.parse.urljoin(public_base, absolute.lstrip("/")) @@ -272,21 +524,159 @@ def part_cover(doc: BeautifulSoup, section) -> None: node.unwrap() +def page_article(site_dir: Path, md: str): + """A page's built article with its web chrome dropped, or None when the page is not built.""" + built = site_dir / md_to_built_html(md) + if not built.exists(): + return None + article = BeautifulSoup(built.read_text(encoding="utf-8"), "html.parser").select_one(CONTENT_SELECTOR) + if article is not None: + drop_noise(article) + return article + + +def plain_heading(heading) -> str: + """A heading's text without the icons some titles carry.""" + for icon in heading.select(".twemoji"): + icon.decompose() + return " ".join(heading.get_text(" ", strip=True).split()) + + +def page_title(site_dir: Path, md: str) -> str: + article = page_article(site_dir, md) + heading = article.find("h1") if article is not None else None + return plain_heading(heading) if heading is not None else md + + +def list_cell(doc: BeautifulSoup, name: str = "td"): + """A cell of a print-list table, set left: pandoc centres a cell that has no alignment.""" + return doc.new_tag(name, attrs={"style": "text-align: left;"}) + + +def list_table(doc: BeautifulSoup, columns: tuple[str, str]): + """An empty print-list table under its two column labels, and the body to fill.""" + table = doc.new_tag("table", attrs={"class": "print-list"}) + head = doc.new_tag("thead") + row = doc.new_tag("tr") + for label in columns: + cell = list_cell(doc, "th") + cell.string = label + row.append(cell) + head.append(row) + body = doc.new_tag("tbody") + table.append(head) + table.append(body) + return table, body + + +def summary_table(doc: BeautifulSoup, generated: Generated, site_dir: Path): + """A two-column table of pages: the title, and the paragraph that opens the page. + + A page that opens with something else is summarized by its second-level + headings, which on a release page name the components released. + """ + table, body = list_table(doc, generated.columns) + for md in generated.pages: + article = page_article(site_dir, md) + heading = article.find("h1") if article is not None else None + row = doc.new_tag("tr") + title = list_cell(doc) + title.string = plain_heading(heading) if heading is not None else md + summary = list_cell(doc) + opening = heading.find_next_sibling() if heading is not None else None + if opening is not None and opening.name == "p": + for link in opening.find_all("a"): + link.unwrap() + for image in opening.find_all("img"): + image.decompose() + for child in list(opening.contents): + summary.append(child.extract()) + elif article is not None: + summary.string = ", ".join(plain_heading(h) for h in article.find_all("h2")) + row.append(title) + row.append(summary) + body.append(row) + return table + + +def groups_table(doc: BeautifulSoup, generated: Generated, site_dir: Path): + """A two-column table of navigation titles: the title, and the names of the pages beneath it. + + A page is named by its navigation title, or by its own title when the + navigation gives it none. + """ + table, body = list_table(doc, generated.columns) + for title, pages in generated.groups: + row = doc.new_tag("tr") + for text in (title, ", ".join(page.title or page_title(site_dir, page.md) for page in pages)): + cell = list_cell(doc) + cell.string = text + row.append(cell) + body.append(row) + return table + + +def section_url(generated: Generated, site_dir: Path, public_base: str) -> str: + """Where a shortened section is complete: its own page online, else the first page it lists.""" + if (site_dir / generated.section / "index.html").is_file(): + path = generated.section + else: + path = md_to_url_path(generated.pages[0]).lstrip("/") + return urllib.parse.urljoin(public_base, path) + + +def render_generated(doc: BeautifulSoup, entry: NavEntry, site_dir: Path, public_base: str) -> list: + """The elements that stand in for a shortened section's pages.""" + generated = entry.generated + if generated.kind == "table": + return [summary_table(doc, generated, site_dir)] + if generated.kind == "groups": + return [groups_table(doc, generated, site_dir)] + name = generated.name or page_title(site_dir, generated.pages[0]) + url = section_url(generated, site_dir, public_base) + elements = [] + if generated.mode == "omit": + heading = doc.new_tag(f"h{min(6, entry.depth + 1)}") + heading.string = name + elements.append(heading) + text = f"The {name} is not part of this print edition. It is part of the online edition: {url}" + else: + text = f"This print edition lists the {name} in short. The complete section is part of the online edition: {url}" + note = doc.new_tag("div", attrs={"class": "admonition info"}) + paragraph = doc.new_tag("p") + paragraph.string = text + note.append(paragraph) + elements.append(note) + return elements + + def merge_pages( - entries: list[NavEntry], site_dir: Path, public_base: str + entries: list[NavEntry], + site_dir: Path, + public_base: str, + rules: list[SectionRule] | None = None, + dropped: set[str] | None = None, ) -> tuple[BeautifulSoup, list[str]]: """Merge the built articles along the navigation into one HTML document. Every top-level entry is a part. It is preceded by a chapter break, so a part starts on a new page, and its page opens with the part's cover (see `part_cover`); a part without a page gets its title and the contents marker. - Returns the document and the navigation pages that had no built HTML. + Generated entries of the print edition's section modes are rendered where + they stand, and a shortened section's links to the pages it drops print as + text. Returns the document and the navigation pages that had no built HTML. """ doc = BeautifulSoup('', "html.parser") valid_ids = {url_to_section_id(md_to_url_path(e.md)) for e in entries if e.md} + dropped_ids = {url_to_section_id(md_to_url_path(md)) for md in dropped or ()} + shortened = [rule.prefix for rule in rules or () if rule.mode != "full"] missing: list[str] = [] for entry in entries: + if entry.generated is not None: + for element in render_generated(doc, entry, site_dir, public_base): + doc.body.append(element) + continue if entry.depth == 0: doc.body.append(doc.new_tag("div", attrs={"class": "chapter-break"})) if entry.md is None: @@ -297,24 +687,20 @@ def merge_pages( heading.insert_after(doc.new_tag("div", attrs={"class": "part-contents"})) continue - built = site_dir / md_to_built_html(entry.md) - article = None - if built.exists(): - page = BeautifulSoup(built.read_text(encoding="utf-8"), "html.parser") - article = page.select_one(CONTENT_SELECTOR) + # page_article drops the web chrome before the ids are namespaced: + # selectors such as #__comments would no longer match afterwards. + article = page_article(site_dir, entry.md) if article is None: missing.append(entry.md) continue - # Before the ids are namespaced: selectors such as #__comments would no - # longer match afterwards. - drop_noise(article) section_id = url_to_section_id(md_to_url_path(entry.md)) section = doc.new_tag("section", attrs={"class": "print-page", "id": section_id}) for child in list(article.children): section.append(child.extract()) namespace_ids(section, section_id) - resolve_links(section, md_to_url_path(entry.md), valid_ids, public_base) + unlink_ids = dropped_ids if any(entry.md.startswith(prefix) for prefix in shortened) else frozenset() + resolve_links(section, md_to_url_path(entry.md), valid_ids, public_base, unlink_ids) demote_headings(section, entry.depth) if entry.depth == 0: part_cover(doc, section) @@ -458,14 +844,54 @@ def typst_path(path: Path) -> str: return "/" + path.resolve().relative_to(Path.cwd().resolve()).as_posix() -def resolve_images(doc: BeautifulSoup, site_dir: Path, stats: Counter, warnings: list[str]) -> None: - """Point images at the built files Typst embeds, and replace what it cannot embed. +def printed_width_pt(image: Image.Image, width: str | None) -> float: + """The width Typst prints an image at in the print edition's text column. + + A percentage is that share of the column. Without one, Typst sizes an image + by the pixel density it declares - 72 dpi when it declares none - and never + wider than the column. + """ + if width and width.strip().endswith("%"): + return TEXT_WIDTH_PT * float(width.strip()[:-1]) / 100 + dpi = image.info.get("dpi") + density = float(dpi[0]) if dpi and dpi[0] else 72.0 + return min(image.width * 72 / density, TEXT_WIDTH_PT) + + +def print_image(source: Path, width: str | None, cache_dir: Path) -> Path: + """A copy of an image for the print edition: flattened onto white, at 300 ppi at its printed width. + + BoD asks for 300 dpi and no transparency. The copy declares 300 dpi, so Typst + prints it at the width the original printed at, resampled up or down with + Lanczos. Copies are cached by the original's content and the pixel width. + """ + data = source.read_bytes() + with Image.open(io.BytesIO(data)) as image: + pixels = max(1, round(printed_width_pt(image, width) / 72 * PRINT_PPI)) + target = cache_dir / f"{hashlib.sha256(data).hexdigest()[:16]}-{pixels}.png" + if target.is_file(): + return target + image.seek(0) + frame = image.convert("RGBA") + flat = Image.new("RGB", frame.size, "white") + flat.paste(frame, mask=frame.getchannel("A")) + height = max(1, round(frame.height * pixels / frame.width)) + cache_dir.mkdir(parents=True, exist_ok=True) + flat.resize((pixels, height), Image.Resampling.LANCZOS).save(target, "PNG", dpi=(PRINT_PPI, PRINT_PPI)) + return target + + +def resolve_images( + doc: BeautifulSoup, site_dir: Path, stats: Counter, warnings: list[str], print_images: Path | None = None +) -> None: + """Point images at the files Typst embeds, and replace what it cannot embed. Emoji that the site loads as Twemoji images become the emoji character, which the vendored emoji font draws. Other remote images - status badges - become their alt text: the build does not fetch from the network. An SVG that keeps its text in foreignObject elements, as Mermaid does by default, - renders without that text in Typst, so it is reported. + renders without that text in Typst, so it is reported. With `print_images`, + raster images point at their print copies in that directory (`print_image`). """ for lightbox in doc.select("a.glightbox"): lightbox.unwrap() @@ -492,7 +918,11 @@ def resolve_images(doc: BeautifulSoup, site_dir: Path, stats: Counter, warnings: continue if path.suffix.lower() == ".svg" and " None: section.unwrap() -def normalize(doc: BeautifulSoup, site_dir: Path) -> tuple[Counter, list[str]]: +def normalize(doc: BeautifulSoup, site_dir: Path, print_images: Path | None = None) -> tuple[Counter, list[str]]: """Rewrite the merged document into elements pandoc's HTML reader understands. Returns counts of what was rewritten and warnings about content the PDF @@ -552,7 +982,7 @@ def normalize(doc: BeautifulSoup, site_dir: Path) -> tuple[Counter, list[str]]: pair_tabs(doc, stats) mark_cards(doc, stats) inline_icons(doc, stats) - resolve_images(doc, site_dir, stats, warnings) + resolve_images(doc, site_dir, stats, warnings, print_images) embedded_media_to_links(doc, stats) settle_internal_links(doc, stats) return stats, warnings @@ -613,6 +1043,24 @@ def run(command: list[str], what: str) -> None: print(f"{what} took {time.monotonic() - started:.0f}s") +def unpadded_pages(compile_command: list[str], typ_path: Path) -> int: + """How many pages the print edition has before it is padded to an even count. + + The style marks the book's last page with ``. Typst cannot add the + blank page itself: a page break that depends on the page count never lets the + layout converge. So the build asks first and passes `pad=true` when needed. + """ + started = time.monotonic() + command = [compile_command[0], "eval", "query().first().value", "--in", str(typ_path)] + command += compile_command[2:] + result = subprocess.run(command, capture_output=True, text=True, check=False) + if result.returncode != 0: + raise click.ClickException(f"typst eval failed: {result.stderr.strip()[:500]}") + pages = int(result.stdout.strip()) + print(f"typst eval took {time.monotonic() - started:.0f}s: {pages} pages before padding") + return pages + + @click.command() @click.option( "--build-version", @@ -621,12 +1069,20 @@ def run(command: list[str], what: str) -> None: help="Which version to stamp on the title page and fold into the file name?", show_default=True, ) +@click.option( + "--edition", "edition_name", + type=click.Choice(EDITIONS), + default="screen", + envvar="PDF_EDITION", + help="Which edition: the screen PDF, or the book block for print on demand?", + show_default=True, +) @click.option( "--output-file", "-o", type=click.Path(exists=False, dir_okay=False, file_okay=True), default=None, envvar="PDF_OUT", - help=f"Where to write the PDF? [default: {DEFAULT_OUT_STEM}-.pdf]", + help=f"Where to write the PDF? [default: {DEFAULT_OUT_STEM}-.pdf, -print.pdf for print]", ) @click.option( "--pandoc", "pandoc_binary", @@ -643,11 +1099,16 @@ def run(command: list[str], what: str) -> None: show_default=True, ) def build_pdf( - build_version: str, output_file: str | None, pandoc_binary: str, typst_binary: str + build_version: str, edition_name: str, output_file: str | None, pandoc_binary: str, typst_binary: str ) -> None: """Build a single PDF of the whole site with pandoc and Typst.""" version = build_version.strip() or "dev" - out = Path(output_file or f"{DEFAULT_OUT_STEM}-{version.replace('.', '-')}.pdf") + print_edition = edition_name == "print" + suffix = "-print" if print_edition else "" + out = Path(output_file or f"{DEFAULT_OUT_STEM}-{version.replace('.', '-')}{suffix}.pdf") + # The print edition merges and normalizes differently, so its intermediate + # files must not overwrite the screen edition's. + work_dir = WORK_DIR / "print" if print_edition else WORK_DIR if not (SITE_DIR / "index.html").exists(): raise click.ClickException(f"{SITE_DIR} is not built - run `task build` first.") @@ -665,11 +1126,17 @@ def build_pdf( entries = load_nav_entries() if not entries: raise click.ClickException(f"no pages found in {NAV_YML}") + rules = load_section_rules() if print_edition else [] + entries, dropped = apply_section_rules(entries, rules) + for rule in rules: + if rule.mode != "full": + count = sum(1 for md in dropped if md.startswith(rule.prefix)) + print(f"Print edition: {rule.prefix} as {rule.mode}, {count} pages dropped") config = load_site_config() public_base = public_base_url(config, version) - doc, missing = merge_pages(entries, SITE_DIR, public_base) + doc, missing = merge_pages(entries, SITE_DIR, public_base, rules, dropped) pages = sum(1 for e in entries if e.md) - len(missing) - headings = sum(1 for e in entries if e.md is None) + headings = sum(1 for e in entries if is_heading_entry(e)) if missing: print( f"WARNING: {len(missing)} page(s) in {NAV_YML} had no built HTML: " @@ -679,14 +1146,14 @@ def build_pdf( ) print(f"Merged {pages} pages and {headings} section headings along {NAV_YML}") - stats, warnings = normalize(doc, SITE_DIR) + stats, warnings = normalize(doc, SITE_DIR, work_dir / "images" if print_edition else None) for warning in warnings: print(f"WARNING: {warning}", file=sys.stderr) print("Normalized " + ", ".join(f"{count} {what}" for what, count in sorted(stats.items()))) - WORK_DIR.mkdir(parents=True, exist_ok=True) - html_path = WORK_DIR / "book.html" - typ_path = WORK_DIR / "book.typ" + work_dir.mkdir(parents=True, exist_ok=True) + html_path = work_dir / "book.html" + typ_path = work_dir / "book.typ" html_path.write_text(str(doc), encoding="utf-8") run( @@ -704,8 +1171,16 @@ def build_pdf( typst, "compile", "--root", ".", "--ignore-system-fonts", "--font-path", str(PDF_ASSETS / "fonts"), ] - for key, value in edition(config, version, public_base, date.today(), source_commit()).items(): + inputs = edition(config, version, public_base, date.today(), source_commit()) + # Only the print edition passes the switch, so the screen edition's Typst + # call is the one it always was. + if print_edition: + inputs["edition"] = "print" + for key, value in inputs.items(): command += ["--input", f"{key}={value}"] + if print_edition and unpadded_pages(command, typ_path) % 2: + # A printed book has an even page count; the style adds a blank last page. + command += ["--input", "pad=true"] run(command + [str(typ_path), str(out)], "typst") print(f"PDF written to {out} ({out.stat().st_size // 1024} KB)") diff --git a/tools/pdf/authors.yml b/tools/pdf/authors.yml new file mode 100644 index 000000000..7ff2db061 --- /dev/null +++ b/tools/pdf/authors.yml @@ -0,0 +1,49 @@ +--- +# Generated by `dec-tool pdf-authors` from the GitHub contributors of +# eccenca/documentation.eccenca.com. Do not edit - run `task pdf:authors`. +repository: eccenca/documentation.eccenca.com +authors: + - id: rpietzsch + commits: 639 + - id: seebi + commits: 532 + - id: irangareddy + commits: 66 + - id: muddymudskipper + commits: 62 + - id: mgns + commits: 61 + - id: sobo + commits: 53 + - id: annamakor + commits: 29 + - id: msaipraneeth + commits: 25 + - id: saradaimi786 + commits: 22 + - id: louiswesterheide + commits: 20 + - id: robertisele + commits: 18 + - id: spl0tt + commits: 12 + - id: white-gecko + commits: 11 + - id: BorderCloud + commits: 10 + - id: haschek + commits: 10 + - id: MaximilianWenzel + commits: 9 + - id: tomatophantastico + commits: 7 + - id: adelahaye-ecc + commits: 5 + - id: dgrtner-ecc + commits: 1 + - id: looooph + commits: 1 + - id: peterfreytag + commits: 1 + - id: pkgut + commits: 1 diff --git a/tools/pdf/filter.lua b/tools/pdf/filter.lua index 6d4b143c0..7d390af8d 100644 --- a/tools/pdf/filter.lua +++ b/tools/pdf/filter.lua @@ -37,7 +37,8 @@ function Div(el) -- Consumed by the enclosing admonition below. return nil elseif c:includes('chapter-break') then - return raw('#pagebreak(weak: true)') + -- A new page on screen, a right-hand page in print; style.typ decides. + return raw('#chapter-break()') elseif c:includes('part-contents') then return raw('#part-contents()') elseif c:includes('admonition') then diff --git a/tools/pdf/print.yml b/tools/pdf/print.yml new file mode 100644 index 000000000..3b6c17c5a --- /dev/null +++ b/tools/pdf/print.yml @@ -0,0 +1,30 @@ +--- +# The print edition of the PDF: `task pdf:print`, see tasks/spec.md. +# tools/build_pdf.py reads `sections`; tools/pdf/style.typ reads the rest. + +# Printed on the title page and in the imprint. +publisher: + name: eccenca GmbH + address: + - Hainstraße 8 + - 04109 Leipzig + - Germany + +# The weight of the body text: light, as on screen, or regular, which prints +# darker in black and white. +body-weight: light + +# How a navigation section is printed, keyed by the docs/ directory its pages +# live in. A section that is not listed prints in full. +# full - as on the site +# list - its overview: the section page and the overview pages of its +# subsections; pages no overview lists become a two-column table of +# title and first paragraph, under `columns` when given; navigation +# titles whose pages an overview lists become one table of the +# titles and their page names +# omit - left out; a note names the online edition +sections: + build/reference/: list + release-notes/: + mode: list + columns: [Release, Summary] diff --git a/tools/pdf/style.typ b/tools/pdf/style.typ index d10b3ea8c..03e07897c 100644 --- a/tools/pdf/style.typ +++ b/tools/pdf/style.typ @@ -12,14 +12,26 @@ // so the PDF does not depend on what is installed where it is built. // ============================================================================= +// -- edition -------------------------------------------------------------------- +// `dec-tool build-pdf --edition print` passes `--input edition=print` for the +// book block of a printed book (tasks/spec.md). Everything that differs between +// the editions branches on this flag; without the input the screen PDF is built +// exactly as before. +#let print-edition = sys.inputs.at("edition", default: "screen") == "print" + // -- palette (house style) ---------------------------------------------------- -#let ec-orange = rgb("#F29100") -#let ec-peach = rgb("#FCF0E0") -#let ec-slate = rgb("#2E3B45") -#let ec-grey = rgb("#6B757C") +// DEVIATION (print): the book prints in black and white on BoD's presses, which +// screen a grey below 20 % black unevenly or not at all. So text and accents +// print black, the orange turns a dark grey that still reads as a rule, the peach +// bands take the lightest grey BoD accepts, and the grounds lighter than that - +// code and note admonitions - are left white (see the rules that use ec-wash). +#let ec-orange = if print-edition { luma(30%) } else { rgb("#F29100") } +#let ec-peach = if print-edition { luma(80%) } else { rgb("#FCF0E0") } +#let ec-slate = if print-edition { luma(0%) } else { rgb("#2E3B45") } +#let ec-grey = if print-edition { luma(40%) } else { rgb("#6B757C") } #let ec-link = rgb("#0563C1") // DEVIATION: the policy palette has no alarm colour; danger admonitions need one. -#let ec-red = rgb("#B3261E") +#let ec-red = if print-edition { luma(0%) } else { rgb("#B3261E") } // DEVIATION: neutral ground for code and for note and info admonitions. #let ec-wash = rgb("#F3F5F6") @@ -78,7 +90,9 @@ #let admonition(kind: "note", title: none, body) = { let accent = if kind in alarm-kinds { ec-red } else if kind in caution-kinds { ec-orange } else { ec-slate } - let ground = if kind in alarm-kinds { ec-red.lighten(92%) } else if kind in caution-kinds { ec-peach } else { ec-wash } + // DEVIATION (print): no ground - the light grounds print below BoD's 20 % + // black - so the bar on the left carries the kind alone. + let ground = if print-edition { none } else if kind in alarm-kinds { ec-red.lighten(92%) } else if kind in caution-kinds { ec-peach } else { ec-wash } block( width: 100%, fill: ground, @@ -118,6 +132,8 @@ // the build passes the widest line in characters, and the text shrinks until // it fits. 0.61 em is the advance of Roboto Mono (0.600) and of the DejaVu Sans // Mono box glyphs (0.602), with a little room; 18pt is the block's inset. +// DEVIATION (print): the text never shrinks below 6 pt, the smallest size that +// stays legible on paper; a wider table then runs past the block's edge. #let codeblock(title: "", columns: none, body) = block(width: 100%, above: 1.2em, below: 1.2em, { if title != "" { block( @@ -136,7 +152,8 @@ } else { layout(region => { let fitting = (region.width - 18pt) / (columns * 0.61) - show raw.where(block: true): set text(size: calc.min(size-code, fitting)) + let size = calc.min(size-code, fitting) + show raw.where(block: true): set text(size: if print-edition { calc.max(6pt, size) } else { size }) body }) } @@ -170,15 +187,63 @@ it.body } +// -- print edition: right-hand starts and page furniture -------------------------- +// A bound book starts a part, its contents and its text on a right-hand (odd) +// page. The break leaves a blank left-hand page when it has to, and Typst gives +// that page the same header and footer as any other. So the break brackets +// itself with two markers: a page strictly between them was inserted blank. +#let recto-break() = { + [#metadata("recto-before") ] + pagebreak(weak: true, to: "odd") + [#metadata("recto-after") ] +} + +// Pages that carry neither header nor footer in the print edition: the title +// page, the imprint, the part covers and blank pages. +#let bare-page(page) = { + if page <= 2 { return true } + if query(heading.where(level: 1)).any(h => h.location().page() == page) { return true } + let pairs = query().zip(query()) + if pairs.any(((before, after)) => before.location().page() < page and page < after.location().page()) { + return true + } + query().any(marker => marker.location().page() == page) +} + +// DEVIATION (print): page numbers sit on the outer edge, as in a bound book. A +// left-hand page names the part beside its number, a right-hand page the page +// the reader is in. The page total means nothing on paper and is left out. +#let print-footer(page-no) = { + set par(justify: false, leading: 0.56em) + set text(size: size-meta, fill: ec-orange) + let started(h) = h.location().page() <= page-no + let parts = query(heading.where(level: 1)).filter(started) + let folio = text(weight: "medium", counter(page).display()) + let running = if parts.len() == 0 { none } else if calc.even(page-no) { parts.last() } else { + let pages = query(heading.where(level: 2).after(parts.last().location())).filter(started) + if pages.len() > 0 { pages.last() } else { parts.last() } + } + let title = if running == none { [] } else { numbered-title(running) } + if calc.even(page-no) { + grid(columns: (auto, 1fr), column-gutter: 1.2em, folio, title) + } else { + grid(columns: (1fr, auto), column-gutter: 1.2em, align(right, title), folio) + } +} + // The label above a contents. #let contents-title = text(size: size-h2, weight: "bold", fill: ec-slate)[Content] +// The break before a part, its contents and its text: a new page on screen, a +// right-hand page in print. tools/pdf/filter.lua places it before every part. +#let chapter-break() = if print-edition { recto-break() } else { pagebreak(weak: true) } + // DEVIATION: a policy has one contents. A part of this book has a cover page - // its title and, if the part has one, the diagram of where it sits - and its // own contents on the pages after: the part's pages and two levels of their // sections, up to the next part. The build places the call after the cover. #let part-contents() = { - pagebreak(weak: true) + chapter-break() contents-title v(0.4em) context { @@ -195,7 +260,38 @@ .after(here()).before(next-part), ) } - pagebreak(weak: true) + chapter-break() +} + +// DEVIATION (print): the back of the title page is the imprint - edition, +// publisher, authors, licence and the online edition - and it carries the +// edition stamp the screen PDF prints in its header. tools/pdf/print.yml holds +// the publisher and the section modes, tools/pdf/authors.yml the authors. +#let imprint(title: "", context-line: "", subtitle: "", generated: "", site-url: "", copyright: "") = { + let config = yaml("print.yml") + let mode(section) = if type(section) == dictionary { section.at("mode", default: "full") } else { section } + let shortened = config.at("sections", default: (:)).values().any(section => mode(section) != "full") + let authors = yaml("authors.yml").authors.map(author => author.id) + set text(size: size-small, fill: ec-slate) + set par(justify: false, leading: 0.6em, spacing: 1.2em) + let entry(label, body) = block(below: 1.3em, [#text(weight: "bold", label) \ #body]) + v(1fr) + entry(title)[#context-line, #subtitle \ Print edition, generated #generated] + entry("Publisher")[#config.publisher.name \ #config.publisher.address.join(linebreak())] + entry("Authors")[The contributors to the documentation by their GitHub IDs, most commits first: #authors.join(", ").] + // The URL is a string: written as markup, Typst would turn it into a link. + entry("Licence")[ + This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License, + #"https://creativecommons.org/licenses/by-sa/4.0/". #copyright + ] + entry("Online edition")[ + #site-url + #if shortened [\ The online edition is the complete reference; this print edition shortens some of its sections.] + ] + entry("Typesetting")[ + Typeset with Typst from the Markdown sources of the documentation, in Roboto, Roboto Mono, Noto Color + Emoji and DejaVu. + ] } // ----------------------------------------------------------------------------- @@ -224,28 +320,41 @@ // -- page furniture (house style geometry) ------------------------------------- set page( paper: "a4", - margin: (top: 3.9cm, bottom: 2.2cm, left: 2.5cm, right: 2.5cm), + // DEVIATION (print): a bound book mirrors its margins - wider inside for the + // binding, narrower outside - and keeps the 16 cm text width. Its header is + // empty, with no logo and no version on text pages, so the top margin + // shrinks to what a page without it needs. + binding: if print-edition { left } else { auto }, + margin: if print-edition { + (top: 2.5cm, bottom: 2.2cm, inside: 3.0cm, outside: 2.0cm) + } else { + (top: 3.9cm, bottom: 2.2cm, left: 2.5cm, right: 2.5cm) + }, header-ascent: 1.14cm, footer-descent: 0.51cm, // The title page carries version and date in its title block, so its // header is the logo alone. - header: context grid( - columns: (1fr, auto), - align(left + top, pad(left: -28pt, image("logo.svg", width: 4cm))), - if here().page() == 1 { [] } else { - align(right + bottom, text(size: size-meta, fill: ec-grey, weight: "regular", { - set par(leading: 0.56em) - [Version #version] - linebreak() - [Generated #generated] - })) - }, - ), + header: if print-edition { none } else { + context grid( + columns: (1fr, auto), + align(left + top, pad(left: -28pt, image("logo.svg", width: 4cm))), + if here().page() == 1 { [] } else { + align(right + bottom, text(size: size-meta, fill: ec-grey, weight: "regular", { + set par(leading: 0.56em) + [Version #version] + linebreak() + [Generated #generated] + })) + }, + ) + }, // DEVIATION: the policy footer repeats the document title. In a book the // chapter and the page the reader is in are the more useful running title. footer: context { let page-no = here().page() - if page-no > 1 { + if print-edition { + if not bare-page(page-no) { print-footer(page-no) } + } else if page-no > 1 { // A long page title wraps; spread to the full width, it would gape. set par(justify: false, leading: 0.56em) let started(h) = h.location().page() <= page-no @@ -267,18 +376,26 @@ ) // -- base typography (house style) ----------------------------------------------- + // DEVIATION (print): tools/pdf/print.yml may set a heavier body weight, which + // prints darker in black and white. + let body-weight = if print-edition { yaml("print.yml").at("body-weight", default: "light") } else { "light" } set text( font: sans, - weight: "light", + weight: body-weight, size: size-body, fill: ec-slate, lang: "en", - hyphenate: false, + // DEVIATION (print): justified text without hyphenation opens gaps that + // a printed page shows more plainly than a screen. Titles stay whole. + hyphenate: print-edition, // A glyph that none of the vendored fonts has falls back to the fonts // built into Typst instead of printing as an empty box. fallback: true, ) - set par(justify: true, leading: 0.76em, spacing: 2.24em) + show heading: set text(hyphenate: false) + // DEVIATION (print): the house spacing suits a two-page policy; over a + // thousand printed pages it costs a tenth of the book (tasks/spec.md, §5). + set par(justify: true, leading: 0.76em, spacing: if print-edition { 1.2em } else { 2.24em }) set list( indent: 1.84em, body-indent: 1.5em, @@ -295,24 +412,53 @@ }, ) set enum(indent: 1.84em, body-indent: 1.64em, spacing: 0.85em) - set block(spacing: 1.52em) - show link: it => { + set block(spacing: if print-edition { 1.0em } else { 1.52em }) + // DEVIATION (print): paper cannot follow a link. A link within the book prints + // the page it leads to, a link out of the book its address in a footnote, and + // neither leaves a link in the PDF (tasks/spec.md, R4). + show link: it => if not print-edition { text(fill: ec-link, underline(it)) if type(it.dest) == str and it.dest.starts-with(regex("https?://")) { external-mark } + } else if type(it.dest) == label { + // Always printed, also for a target on the same page: a reference that + // appears only across pages moves lines, which moves the target back, and + // the layout never settles. + it.body + context [~(p.~#numbering("1", ..counter(page).at(locate(it.dest))))] + } else if type(it.dest) == str { + let address = it.dest.trim("mailto:", at: start) + it.body + // An address that prints as its own text needs no footnote repeating it. + if it.body.at("text", default: none) != address { footnote(address) } + } else { + it.body + } + // Footnotes carry addresses: set small, and breakable after / . _ -. + show footnote.entry: set text(size: size-table) + show footnote.entry: it => { + show regex("[/._-]"): mark => mark + sym.zws + it } + // DEVIATION (print): contents entries without link annotations. + show outline.entry: it => if print-edition { block(it.indented(it.prefix(), it.inner())) } else { it } // -- code --------------------------------------------------------------------- show raw: set text(font: mono, weight: "regular") // No horizontal extent: the table rule below splits a code span into several // text runs at its break opportunities, and a run whose background reaches // past its own edge paints over the last glyph of the run before it. - show raw.where(block: false): it => highlight(fill: ec-wash, extent: 0pt, text(size: 0.84em, it)) + // DEVIATION (print): no ground behind code, which would print below BoD's 20 % + // black. The monospace face marks a code span, a thin frame a code block. + show raw.where(block: false): it => if print-edition { text(size: 0.84em, it) } else { + highlight(fill: ec-wash, extent: 0pt, text(size: 0.84em, it)) + } // The size is a show-set rule of its own, not part of the block below, so // that codeblock() can shrink a terminal table from an inner scope. show raw.where(block: true): set text(size: size-code) show raw.where(block: true): it => block( width: 100%, - fill: ec-wash, + fill: if print-edition { none } else { ec-wash }, + stroke: if print-edition { 0.5pt + ec-grey } else { none }, inset: (x: 9pt, y: 8pt), radius: (bottom-left: 2pt, bottom-right: 2pt), { @@ -320,6 +466,26 @@ it }, ) + // DEVIATION (print): a line of code breaks only at spaces and a few marks, so + // a long URL or identifier ran past the frame. A zero-width space after + // / . _ - & ; = ? , lets it wrap inside; the spaces would end up in copied + // code, which paper does not have. They go into the block's text: a regex + // show rule exceeds Typst's grouping depth on a long block. Defined after the + // frame, so it runs first and the frame takes the new block. A block without + // a mark to break after, or with a zero-width space already, is left as it + // is: every block the rule replaces holds one, which ends the recursion. + // Terminal tables keep their lines: codeblock() shrinks them to fit. + show raw.where(block: true): it => if ( + not print-edition + or it.text.contains(sym.zws) + or not it.text.contains(regex("[/._&;=?,-]")) + or it.text.contains(regex("[─-╿]")) + ) { it } else { + let fields = it.fields() + let _ = fields.remove("text") + let _ = fields.remove("lines", default: none) + raw(..fields, it.text.replace(regex("[/._&;=?,-]"), mark => mark.text + sym.zws)) + } // -- headings ------------------------------------------------------------------- // DEVIATION: one level deeper than a policy. A navigation section is a chapter @@ -370,6 +536,33 @@ show heading.where(level: 6): it => block(above: 1.3em, below: 0.7em, sticky: true, text(size: size-h3, weight: "medium", fill: ec-grey, it.body)) + // -- figures ---------------------------------------------------------------------- + // DEVIATION: image figures carry no number. The site numbers none, and pages + // that need numbers write them into the caption and refer to them in the text + // ("see figure 3"); a book-wide number in front printed "Figure 15: Figure 2:". + show figure.where(kind: image): set figure(numbering: none) + // DEVIATION: a figure does not break across pages, and Typst does not shrink an + // image taller than the page, so a tall screenshot at the column width ran into + // the footer. An image in a figure that is taller than the page body, less room + // for a two-line caption, is scaled down to that height, in proportion. Only + // images in figures: `layout` is block-level, and an image in a line of text + // stays inline. The scaled image has a height, which ends the recursion. + show figure.where(kind: image): it => { + show image: img => if img.height != auto { img } else { + layout(region => { + let room = region.height - 4em.to-absolute() + if measure(img, width: region.width).height <= room { img } else { + let fields = img.fields() + let source = fields.remove("source") + let _ = fields.remove("width", default: none) + let _ = fields.remove("height", default: none) + image(source, ..fields, height: room) + } + }) + } + it + } + // -- tables: booktabs (house style) ----------------------------------------------- set table( inset: (x: 5pt, y: 5pt), @@ -404,7 +597,16 @@ // DEVIATION: a page of its own. A policy runs from its title block straight // into the text; a manual of this length gets a title page, and its contents // start on the next one. The title block itself is the house style's. - v(3.2cm) + // + // DEVIATION (print): page 1 of the book block. The header that carries the + // logo on screen is empty in print, so the logo sits on the page itself. The + // page names the publisher; date, link and copyright move to the imprint. + if print-edition { + place(top + left, dx: -28pt, dy: -1.3cm, image("logo.svg", width: 4cm)) + v(4.6cm) + } else { + v(3.2cm) + } text(size: size-eyebrow, fill: ec-grey, weight: "regular", tracking: 0.06em, upper(context-line)) v(0.35em, weak: true) block( @@ -418,17 +620,31 @@ align(right, text(size: size-subtitle, weight: "light", fill: ec-slate, subtitle)) v(0.5em, weak: true) line(length: 100%, stroke: 0.5pt + ec-grey.lighten(50%)) - v(0.5em, weak: true) - align(right, text(size: size-meta, fill: ec-grey, weight: "regular")[Generated #generated]) - v(1fr) - block(text(size: size-meta, fill: ec-grey, weight: "regular", { - set par(leading: 0.56em, justify: false) - link(site-url) - linebreak() - copyright - })) + if print-edition { + v(1fr) + text(size: size-subtitle, weight: "regular", fill: ec-slate, yaml("print.yml").publisher.name) + } else { + v(0.5em, weak: true) + align(right, text(size: size-meta, fill: ec-grey, weight: "regular")[Generated #generated]) + v(1fr) + block(text(size: size-meta, fill: ec-grey, weight: "regular", { + set par(leading: 0.56em, justify: false) + link(site-url) + linebreak() + copyright + })) + } pagebreak() + // -- imprint (print) ------------------------------------------------------------------ + if print-edition { + imprint( + title: title, context-line: context-line, subtitle: subtitle, + generated: generated, site-url: site-url, copyright: copyright, + ) + pagebreak() + } + // -- contents ---------------------------------------------------------------------- // The parts only. Each part opens with the contents of its pages. contents-title @@ -439,7 +655,21 @@ show outline.entry: set block(above: 1.1em) outline(title: none, depth: 1, indent: auto) } - pagebreak(weak: true) + // In print the break before the first part follows directly: a weak break here + // would leave the blank page before part A outside its markers. + if not print-edition { + pagebreak(weak: true) + } body + + // DEVIATION (print): the build reads the page count from this marker and asks + // for one blank last page when it is odd (tools/build_pdf.py, unpadded_pages). + if print-edition { + context [#metadata(here().page()) ] + if sys.inputs.at("pad", default: "false") == "true" { + pagebreak() + [#metadata("pad") ] + } + } } diff --git a/tools/pdf_authors.py b/tools/pdf_authors.py new file mode 100644 index 000000000..0e72826b3 --- /dev/null +++ b/tools/pdf_authors.py @@ -0,0 +1,104 @@ +"""Write the author list the imprint of the print edition prints. + +The authors are the GitHub accounts that contributed to the documentation +repository, most commits first. Anonymous contributions - commits whose e-mail +maps to no GitHub account - bot accounts and software agents are not listed. + +The list is committed as tools/pdf/authors.yml, so a PDF build stays offline +and reproducible, and a changed list shows up in review. Invoked by +`task pdf:authors`; GITHUB_TOKEN or GH_TOKEN is used when set. +""" +from __future__ import annotations + +import json +import os +import re +import urllib.request +from pathlib import Path + +import click +import yaml + +REPOSITORY = "eccenca/documentation.eccenca.com" +AUTHORS_YML = Path("tools/pdf/authors.yml") +API = "https://api.github.com" +# Agents commit under ordinary accounts of type User, so they are named here. +AGENT = re.compile(r"claude|codex", re.IGNORECASE) + + +def select_authors(contributors: list[dict]) -> list[dict]: + """The contributors the imprint lists, in its order: most commits first, ties by ID, case-insensitive.""" + people = [ + c for c in contributors + if c.get("type") == "User" and c.get("login") and not AGENT.search(c["login"]) + ] + people.sort(key=lambda c: (-int(c["contributions"]), c["login"].casefold())) + return [{"id": c["login"], "commits": int(c["contributions"])} for c in people] + + +def next_page(link_header: str) -> str | None: + """The `rel="next"` URL of a GitHub Link header, if there is one.""" + match = re.search(r'<([^>]+)>;\s*rel="next"', link_header) + return match.group(1) if match else None + + +def fetch_contributors(repository: str, token: str | None) -> list[dict]: + """Every contributor of a repository, following the API's pagination. + + Without `anon=1` the API leaves out contributions that map to no account. + """ + url: str | None = f"{API}/repos/{repository}/contributors?per_page=100" + contributors: list[dict] = [] + while url: + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + if token: + request.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(request, timeout=30) as response: + contributors.extend(json.load(response)) + url = next_page(response.headers.get("Link", "")) + return contributors + + +class IndentedDumper(yaml.SafeDumper): + """Indents list items under their key, as the repository's yamllint rules require.""" + + def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: + return super().increase_indent(flow, False) + + +def authors_yaml(repository: str, authors: list[dict]) -> str: + """The committed file: a header naming its source, then the list in imprint order.""" + header = ( + "---\n" + "# Generated by `dec-tool pdf-authors` from the GitHub contributors of\n" + f"# {repository}. Do not edit - run `task pdf:authors`.\n" + ) + body = yaml.dump( + {"repository": repository, "authors": authors}, + Dumper=IndentedDumper, sort_keys=False, allow_unicode=True, + ) + return header + body + + +@click.command(name="pdf-authors") +@click.option( + "--repository", + default=REPOSITORY, + help="Which GitHub repository's contributors are the authors?", + show_default=True, +) +@click.option( + "--output-file", "-o", + type=click.Path(exists=False, dir_okay=False, file_okay=True), + default=str(AUTHORS_YML), + help="Where to write the author list?", + show_default=True, +) +def pdf_authors(repository: str, output_file: str) -> None: + """Write the imprint's author list from the repository's GitHub contributors.""" + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + authors = select_authors(fetch_contributors(repository, token)) + if not authors: + raise click.ClickException(f"no contributors found for {repository}") + Path(output_file).write_text(authors_yaml(repository, authors), encoding="utf-8") + print(f"{len(authors)} authors written to {output_file}") diff --git a/tools/update_di_reference.py b/tools/update_di_reference.py index 6894509e0..b4f75187b 100644 --- a/tools/update_di_reference.py +++ b/tools/update_di_reference.py @@ -25,8 +25,20 @@ undefined=StrictUndefined ) +# A fenced block, with or without a language. Squeezed onto one line it is no +# fence, but rumdl --fix still takes it for one and inserts a blank line before +# it - which ends the operator table whose row the description is. +FENCED_BLOCK = re.compile(r"```(?:\w*\n)?(.*?)```", re.DOTALL) + + +def code_span(match: re.Match) -> str: + """A fenced block's content as a code span, delimited by two backticks when it holds one.""" + content = re.sub(r"\s+", " ", match.group(1)).strip() + return f"`` {content} ``" if "`" in content else f"`{content}`" + + def stripped_single_line(value: str) -> str: - return re.sub(r"\s+", " ", value).strip() + return re.sub(r"\s+", " ", FENCED_BLOCK.sub(code_span, value)).strip() class PluginReference(BaseModel): """Reference to a related plugin.""" From f344992954c575ab8618d0bd4fe62bbeb1e09a15 Mon Sep 17 00:00:00 2001 From: Rene Pietzsch Date: Tue, 15 Sep 2026 12:24:43 +0200 Subject: [PATCH 02/15] print author names instead of GitHub IDs in the imprint The imprint listed contributors by their GitHub IDs. - task pdf:authors stores each profile name in tools/pdf/authors.yml and adds new authors to authors.names in tools/pdf/print.yml - print.yml names authors (with titles) and excludes IDs; excluded names are never looked up - the build prints the given name, else the profile name, else the ID, and warns about each ID it prints - use the GitHub CLI token when no token is set, and end a rate limit, a rejected token or a network error with a message --- Taskfile.yml | 19 ++- tasks/backlog.md | 20 ++- tasks/spec.md | 20 ++- tests/test_pdf_authors.py | 262 ++++++++++++++++++++++++++++++++++- tools/README.md | 3 +- tools/build_pdf.py | 12 ++ tools/pdf/authors.yml | 28 +++- tools/pdf/print.yml | 38 +++++- tools/pdf/style.typ | 11 +- tools/pdf/template.typ | 1 + tools/pdf_authors.py | 277 +++++++++++++++++++++++++++++++++++--- 11 files changed, 640 insertions(+), 51 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 93956aecf..ad056052a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -210,10 +210,10 @@ tasks: imprint, links printed as page references and footnotes, and images normalized to 300 ppi without transparency. - tools/pdf/print.yml configures the edition: the publisher, and how each - navigation section is printed (full, list or omit). - tools/pdf/authors.yml is the imprint's author list; refresh it with - `task pdf:authors`. + tools/pdf/print.yml configures the edition: the publisher, the authors' + names and exclusions, and how each navigation section is printed (full, + list or omit). tools/pdf/authors.yml is the imprint's author list; + refresh it with `task pdf:authors`. Writes dist/documentation-eccenca-com--print.pdf; the merged HTML, the Typst source and the normalized images stay in dist/pdf/print/. The @@ -229,9 +229,14 @@ tasks: desc: Refresh the author list of the print edition's imprint summary: | Writes tools/pdf/authors.yml from the GitHub contributors of this - repository: most commits first, without anonymous contributions, bot - accounts and software agents. Commit the result. Uses GITHUB_TOKEN or - GH_TOKEN when set; unauthenticated requests are rate limited. + repository and the names their GitHub profiles show: most commits + first, without anonymous contributions, bot accounts, software agents + and the IDs excluded in tools/pdf/print.yml. Adds each author that + print.yml does not list yet to its authors.names, without a name, and + warns about each author who would print as a GitHub ID. Commit both + files. Uses GITHUB_TOKEN or GH_TOKEN, else the token of a logged-in + GitHub CLI (`gh auth login`); without a token GitHub allows 60 + requests an hour, and a run takes one per author. deps: - task: install cmds: diff --git a/tasks/backlog.md b/tasks/backlog.md index a0e658bb7..33c3ed484 100644 --- a/tasks/backlog.md +++ b/tasks/backlog.md @@ -10,7 +10,7 @@ Every task names how it is verified. A task is not done until that verification ## P0 - Decisions - done -Spec §4, D1-D11: BoD, A4, black and white on 80 g, no ISBN, GitHub IDs as authors, separate screen and +Spec §4, D1-D11: BoD, A4, black and white on 80 g, no ISBN, authors by name (first GitHub IDs, revised 2026-09-15), separate screen and print editions, section modes with A.3 and Release Notes as lists, page references and URL footnotes, no logo or version on text pages, 10 pt body, optional Ghostscript normalization. @@ -119,6 +119,21 @@ exclusion of bots, agents and anonymous entries, pagination, file format), `task generated `tools/pdf/authors.yml` lists the 22 IDs of spec §3 in the same order and passes yamllint - list items are indented, which PyYAML's default dumper does not do. +**Revised 2026-09-15 - names instead of IDs (D4):** `dec-tool pdf-authors` also writes the name each +GitHub profile shows (`GET /users/`), and skips the IDs in `authors.exclude` of `tools/pdf/print.yml` +before any lookup. The build applies `authors.names` - for a profile without a name, or to add a title - +and the exclusions to the committed list (`load_imprint_names`), passes the names to Typst as the +`authors` input, and warns about each author it still prints as an ID. 16 of the 22 profiles show a +name; `rpietzsch`, `annamakor`, `MaximilianWenzel`, `adelahaye-ecc`, `dgrtner-ecc` and `pkgut` need an +entry. Each run also adds the authors `authors.names` does not list yet, without a name and with the +profile's name in a comment (`prefill_names`); it edits `print.yml` as text, so comments stay, and reads +it back to check that only those entries changed. A name left empty prints the profile's. First run: all +22 IDs added; a second run adds none. Requests use GITHUB_TOKEN or GH_TOKEN, else the token of a +logged-in GitHub CLI; without one GitHub allows 60 requests an hour, and a run takes one per author. A +failed request - rate limit with its reset time, rejected token, unreachable API - ends the command with +a message instead of a traceback, before any file is written. `tests/test_pdf_authors.py` has 28 tests: +rules, exclusion, profile names, the order of names, the prefill, token sources and API failures. + --- ## P7 - Imprint - **done** @@ -136,6 +151,9 @@ edition - with a sentence that the print edition shortens sections whenever `pri `list` or `omit` - and the typesetting. Checked on the rendered page and in its text: the author IDs match `authors.yml` in order. The licence URL is set as a string, because Typst links URLs written in markup. +**Revised 2026-09-15:** the imprint prints the authors' names, not their IDs. `imprint()` no longer +reads `authors.yml`; the build passes the names as the `authors` input (P6). + --- ## P8 - Section modes - **done** diff --git a/tasks/spec.md b/tasks/spec.md index 843af395d..9d5e3a5d8 100644 --- a/tasks/spec.md +++ b/tasks/spec.md @@ -101,11 +101,15 @@ Page 2, verso, no page furniture. Contents: #### Authors -Rule (D4): GitHub accounts of the contributors to +Rule (D4, revised 2026-09-15): GitHub accounts of the contributors to [eccenca/documentation.eccenca.com](https://github.com/eccenca/documentation.eccenca.com), **most -commits first**, printed as their **GitHub IDs**. Not listed: anonymous contributions (commits whose -e-mail maps to no GitHub account), bot accounts and software agents (codex, claude). Ties are ordered by -ID, case-insensitive. +commits first**, printed with their **names**: the name in the hand-maintained `authors.names` of +`tools/pdf/print.yml` - for a profile without a name, or to add a title - else the name the GitHub +profile shows, else the GitHub ID. Not listed: anonymous contributions (commits whose e-mail maps to no +GitHub account), bot accounts, software agents (codex, claude) and the IDs in `authors.exclude`, whose +names are not looked up. Ties are ordered by ID, case-insensitive. `task pdf:authors` adds each author +that `authors.names` does not list yet, without a name and with the profile's name in a comment, so all +names are maintained in one place; an entry left empty prints the profile's name. Measured with `gh api repos/eccenca/documentation.eccenca.com/contributors` on 2026-09-14: @@ -136,6 +140,10 @@ Measured with `gh api repos/eccenca/documentation.eccenca.com/contributors` on 2 Dropped: two anonymous entries (33 and 2 commits). +Names, checked with `gh api users/` on 2026-09-15: 16 of the 22 profiles show a name. `rpietzsch`, +`annamakor`, `MaximilianWenzel`, `adelahaye-ecc`, `dgrtner-ecc` and `pkgut` show none; they need an entry +in `authors.names`, or they print as their ID, and the build warns about each. + - **No software agent is in the list today.** Claude's commits carry a `Co-Authored-By` trailer and count for the human author; GitHub does not list co-authors as contributors. The exclusion rule still belongs in the tooling. @@ -156,7 +164,7 @@ All made 2026-09-14. | D1 | Scope | The print edition reduces sections instead of printing them in full, **configurable per section** as `full`, `list` or `omit`. Default: A.3 Task and Operator Reference → `list`, H Release Notes → `list`, everything else `full` (§5) | | D2 | Provider | **BoD** (§2) | | D3 | ISBN | none for now; may come later | -| D4 | Authors | GitHub IDs, most commits first, no anonymous entries, no bots or agents (§3) | +| D4 | Authors | names, most commits first: from `authors.names` in `print.yml`, else the GitHub profile, else the ID; no anonymous entries, bots, agents or excluded IDs (§3). Revised 2026-09-15 - first decided as GitHub IDs | | D5 | Colour and paper | black and white interior on 80 g paper. BoD offers 80 g to publishers only - eccenca GmbH needs a publisher account | | D6 | Editions | separate screen and print PDFs. `task pdf` stays the screen edition, unchanged; `task pdf:print` builds the book block, switched by a Typst input (`edition=print`) and a print configuration | | D7 | Trim size | A4 | @@ -347,7 +355,7 @@ the same task, for checking the palette (P11). - `task pdf` builds the screen PDF exactly as before: 1680 pages, unchanged look - `task pdf:print` builds the book block with the configuration in `tools/pdf/print.yml` - page 1 is the title page naming eccenca GmbH as publisher; page 2 is the imprint with the publisher's - address and the authors from `tools/pdf/authors.yml`, most commits first + address and the authors by name, from `tools/pdf/authors.yml` and `print.yml`, most commits first - on every numbered page the page number sits at the outer edge: left on even pages, right on odd pages - checked by the preflight report, not by eye - title page, imprint, part covers and blank pages carry no header and no footer; text pages carry no diff --git a/tests/test_pdf_authors.py b/tests/test_pdf_authors.py index ec5046811..2ada8ce97 100644 --- a/tests/test_pdf_authors.py +++ b/tests/test_pdf_authors.py @@ -1,7 +1,50 @@ """Test the author list of the print edition's imprint""" +import shutil +import subprocess +import time +import urllib.error +import urllib.request + +import click +import pytest import yaml -from tools.pdf_authors import authors_yaml, next_page, select_authors +from tools.pdf_authors import ( + AuthorRules, + add_names, + authors_yaml, + get_json, + github_token, + imprint_names, + load_author_rules, + load_imprint_names, + next_page, + prefill_names, + select_authors, +) + +# The shape of tools/pdf/print.yml: comments, and sections before and after `authors`. +PRINT_YML = """\ +--- +# The print edition. +publisher: + name: eccenca GmbH + +authors: + # GitHub ID: the name to print. + names: {} + # GitHub IDs never to list. + exclude: [] + +sections: + build/reference/: list +""" + +AUTHORS = [ + {"id": "seebi", "name": "Jane Doe", "commits": 3}, + {"id": "nameless", "name": None, "commits": 2}, + {"id": "Hubot", "name": "John Roe", "commits": 1}, +] def test_most_commits_first_and_ties_by_id_case_insensitive(): @@ -25,6 +68,25 @@ def test_bots_agents_and_anonymous_contributions_are_not_authors(): assert select_authors(contributors) == [{"id": "sobo", "commits": 53}] +def test_excluded_ids_are_not_authors_whatever_their_case(): + contributors = [ + {"login": "seebi", "contributions": 532, "type": "User"}, + {"login": "BorderCloud", "contributions": 10, "type": "User"}, + ] + rules = AuthorRules(names={}, exclude=frozenset({"bordercloud"})) + assert select_authors(contributors, rules) == [{"id": "seebi", "commits": 532}] + + +def test_names_are_the_profile_names_and_a_blank_one_is_none(): + authors = [{"id": "a", "commits": 3}, {"id": "b", "commits": 2}, {"id": "c", "commits": 1}] + profiles = {"a": " Jane Doe ", "b": "", "c": None} + assert add_names(authors, profiles.get) == [ + {"id": "a", "name": "Jane Doe", "commits": 3}, + {"id": "b", "name": None, "commits": 2}, + {"id": "c", "name": None, "commits": 1}, + ] + + def test_pagination_follows_the_next_link_only(): header = ( '; rel="next", ' @@ -35,11 +97,197 @@ def test_pagination_follows_the_next_link_only(): def test_the_file_names_its_source_and_keeps_the_order(): - text = authors_yaml("eccenca/docs", [{"id": "b", "commits": 2}, {"id": "a", "commits": 1}]) + authors = [{"id": "b", "name": "Jane Doe", "commits": 2}, {"id": "a", "name": None, "commits": 1}] + text = authors_yaml("eccenca/docs", authors) assert text.startswith("---\n# Generated by `dec-tool pdf-authors`") # yamllint's default rules want list items indented under their key. - assert "authors:\n - id: b\n commits: 2\n" in text - assert yaml.safe_load(text) == { - "repository": "eccenca/docs", - "authors": [{"id": "b", "commits": 2}, {"id": "a", "commits": 1}], - } + assert "authors:\n - id: b\n name: Jane Doe\n commits: 2\n" in text + assert yaml.safe_load(text) == {"repository": "eccenca/docs", "authors": authors} + + +def test_rules_read_names_and_exclusions_case_insensitive(tmp_path): + path = tmp_path / "print.yml" + path.write_text("authors:\n names:\n Octocat: Dr. Jane Doe\n exclude:\n - Hubot\n") + assert load_author_rules(path) == AuthorRules(names={"octocat": "Dr. Jane Doe"}, exclude=frozenset({"hubot"})) + + +def test_without_an_authors_section_nothing_is_renamed_or_excluded(tmp_path): + path = tmp_path / "print.yml" + path.write_text("publisher:\n name: eccenca GmbH\n") + assert load_author_rules(path) == AuthorRules(names={}, exclude=frozenset()) + + +@pytest.mark.parametrize( + "section", + [ + "authors:\n exclude: hubot\n", + "authors:\n names:\n - octocat\n", + "authors:\n names:\n octocat: 42\n", + ], +) +def test_malformed_rules_fail(tmp_path, section): + path = tmp_path / "print.yml" + path.write_text(section) + with pytest.raises(click.ClickException, match="authors"): + load_author_rules(path) + + +def test_a_name_left_empty_is_not_given(tmp_path): + path = tmp_path / "print.yml" + path.write_text("authors:\n names:\n octocat:\n hubot: ''\n") + assert load_author_rules(path) == AuthorRules(names={}, exclude=frozenset()) + + +def test_prefill_lists_every_author_with_the_profile_name_as_a_comment(): + text, added = prefill_names(PRINT_YML, AUTHORS) + assert added == ["seebi", "nameless", "Hubot"] + assert ( + " # GitHub ID: the name to print.\n" + " names:\n" + " seebi: # GitHub profile: Jane Doe\n" + " nameless: # no name on the GitHub profile\n" + " Hubot: # GitHub profile: John Roe\n" + " # GitHub IDs never to list.\n" + " exclude: []\n" + ) in text + data = yaml.safe_load(text) + # Left empty, an entry gives no name: the imprint keeps printing the profile's. + assert data["authors"] == {"names": {"seebi": None, "nameless": None, "Hubot": None}, "exclude": []} + assert data["publisher"] == {"name": "eccenca GmbH"} + assert data["sections"] == {"build/reference/": "list"} + assert text.startswith("---\n# The print edition.\n") + + +def test_prefill_keeps_the_names_given_and_adds_only_the_ids_not_listed(tmp_path): + given = PRINT_YML.replace(" names: {}\n", " names:\n SEEBI: Dr. Jane Doe\n") + text, added = prefill_names(given, AUTHORS) + assert added == ["nameless", "Hubot"] + assert ( + " names:\n" + " SEEBI: Dr. Jane Doe\n" + " nameless: # no name on the GitHub profile\n" + " Hubot: # GitHub profile: John Roe\n" + " # GitHub IDs never to list.\n" + ) in text + path = tmp_path / "print.yml" + path.write_text(text) + assert load_author_rules(path) == AuthorRules(names={"seebi": "Dr. Jane Doe"}, exclude=frozenset()) + + +def test_prefill_skips_excluded_ids_and_a_second_run_changes_nothing(): + excluding = PRINT_YML.replace(" exclude: []\n", " exclude:\n - hubot\n") + once, added = prefill_names(excluding, AUTHORS) + assert added == ["seebi", "nameless"] + assert prefill_names(once, AUTHORS) == (once, []) + + +def test_prefill_adds_an_authors_section_where_there_is_none(): + text, added = prefill_names("---\npublisher:\n name: eccenca GmbH\n", [{"id": "null", "name": None, "commits": 1}]) + assert added == ["null"] + # A login YAML would read as something else is quoted. + assert "\nauthors:\n names:\n 'null': # no name on the GitHub profile\n exclude: []\n" in text + assert yaml.safe_load(text) == {"publisher": {"name": "eccenca GmbH"}, "authors": {"names": {"null": None}, "exclude": []}} + + +def failing_urlopen(error): + def urlopen(request, timeout): + raise error + return urlopen + + +def rate_limited(reset: int) -> urllib.error.HTTPError: + headers = {"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset)} + return urllib.error.HTTPError("https://api.github.com/users/x", 403, "rate limit exceeded", headers, None) + + +def test_a_rate_limit_fails_with_the_reset_time_and_how_to_raise_the_limit(monkeypatch): + reset = 1_900_000_000 + monkeypatch.setattr(urllib.request, "urlopen", failing_urlopen(rate_limited(reset))) + with pytest.raises(click.ClickException) as raised: + get_json("https://api.github.com/users/x", None) + message = raised.value.message + assert "rate limit" in message + assert time.strftime("%H:%M", time.localtime(reset)) in message + assert "GITHUB_TOKEN" in message and "gh auth login" in message + + +def test_with_a_token_a_rate_limit_fails_with_the_reset_time_only(monkeypatch): + monkeypatch.setattr(urllib.request, "urlopen", failing_urlopen(rate_limited(1_900_000_000))) + with pytest.raises(click.ClickException) as raised: + get_json("https://api.github.com/users/x", "a-token") + assert "rate limit" in raised.value.message + assert "GITHUB_TOKEN" not in raised.value.message + + +@pytest.mark.parametrize( + "error, expected", + [ + (urllib.error.HTTPError("https://api.github.com/users/gone", 404, "Not Found", {}, None), "HTTP 404 Not Found"), + (urllib.error.HTTPError("https://api.github.com/users/x", 401, "Unauthorized", {}, None), "rejected the token"), + (urllib.error.URLError("nodename nor servname provided"), "cannot reach the GitHub API"), + (TimeoutError("timed out"), "cannot reach the GitHub API"), + ], +) +def test_other_failures_end_with_a_message_instead_of_a_traceback(monkeypatch, error, expected): + monkeypatch.setattr(urllib.request, "urlopen", failing_urlopen(error)) + with pytest.raises(click.ClickException, match=expected): + get_json("https://api.github.com/users/gone", "a-token") + + +def without_token_variables(monkeypatch): + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + + +def test_the_token_of_the_environment_comes_first(monkeypatch): + without_token_variables(monkeypatch) + monkeypatch.setenv("GH_TOKEN", "from-env") + monkeypatch.setattr(shutil, "which", lambda name: pytest.fail("the GitHub CLI is not asked")) + assert github_token() == ("from-env", "GH_TOKEN") + + +def test_without_one_in_the_environment_the_github_cli_lends_its_token(monkeypatch): + without_token_variables(monkeypatch) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/gh") + monkeypatch.setattr( + subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, stdout="gho_cli\n", stderr="") + ) + assert github_token() == ("gho_cli", "the GitHub CLI") + + +@pytest.mark.parametrize( + "which, run", + [ + (lambda name: None, None), + ( + lambda name: "/usr/local/bin/gh", + lambda *args, **kwargs: subprocess.CompletedProcess(args, 1, stdout="", stderr="not logged in"), + ), + ], +) +def test_without_a_variable_or_a_logged_in_github_cli_there_is_no_token(monkeypatch, which, run): + without_token_variables(monkeypatch) + monkeypatch.setattr(shutil, "which", which) + if run is not None: + monkeypatch.setattr(subprocess, "run", run) + assert github_token() == (None, None) + + +def test_the_imprint_prints_a_given_name_before_the_profile_name_and_the_id_last(): + authors = [ + {"id": "a", "name": "Jane Doe", "commits": 4}, + {"id": "B", "name": "John Roe", "commits": 3}, + {"id": "c", "name": None, "commits": 2}, + {"id": "d", "name": "Richard Miles", "commits": 1}, + ] + rules = AuthorRules(names={"b": "Dr. John Roe"}, exclude=frozenset({"d"})) + # The names in imprint order, and the IDs that print as themselves for want of a name. + assert imprint_names(authors, rules) == (["Jane Doe", "Dr. John Roe", "c"], ["c"]) + + +def test_the_imprint_reads_the_generated_list_and_the_rules(tmp_path): + authors_yml = tmp_path / "authors.yml" + authors_yml.write_text(authors_yaml("eccenca/docs", [{"id": "a", "name": None, "commits": 1}])) + print_yml = tmp_path / "print.yml" + print_yml.write_text("authors:\n names:\n a: Jane Doe\n") + assert load_imprint_names(authors_yml, print_yml) == (["Jane Doe"], []) diff --git a/tools/README.md b/tools/README.md index 5b77b8406..de9df3d43 100644 --- a/tools/README.md +++ b/tools/README.md @@ -10,7 +10,8 @@ supported entry points and pass the right options. | Command | Used by | Purpose | | :------ | :------ | :------ | | `build-navigation` | `task update:navigation`, `task check:navigation` | Build `nav.yml` from the `docs/**/.pages` files; `--check` diffs instead of writing and fails on drift | -| `build-pdf` | `task pdf` | Merge the built `site/` along `nav.yml` into one document and typeset it as a single PDF with pandoc and Typst; style, filter and fonts are in `pdf/` | +| `build-pdf` | `task pdf`, `task pdf:print` | Merge the built `site/` along `nav.yml` into one document and typeset it as a single PDF with pandoc and Typst; style, filter and fonts are in `pdf/` | +| `pdf-authors` | `task pdf:authors` | Write the print edition's author list `pdf/authors.yml` from the GitHub contributors and the names their profiles show; names and exclusions are kept in `pdf/print.yml`, where it adds each new author without a name | | `check-zensical-output` | `task check:output` | Inspect the built `site/` and fail if a feature we reimplemented for Zensical regressed | | `localize-bundle-assets` | `task build` | Rewrite the third-party asset URLs Zensical bakes into its JavaScript bundle to the vendored copies | | `update-icons` | `task update:icons` | Fetch the eccenca icon set from the gui-elements repository | diff --git a/tools/build_pdf.py b/tools/build_pdf.py index 2d2ba7619..371924e4c 100644 --- a/tools/build_pdf.py +++ b/tools/build_pdf.py @@ -38,6 +38,8 @@ from bs4 import BeautifulSoup, NavigableString from PIL import Image +from tools.pdf_authors import load_imprint_names + DEFAULT_OUT_STEM = "dist/documentation-eccenca-com" SITE_DIR = Path("site") NAV_YML = Path("nav.yml") @@ -1176,6 +1178,16 @@ def build_pdf( # call is the one it always was. if print_edition: inputs["edition"] = "print" + # The imprint's authors: the committed list with the names and + # exclusions of print.yml applied. + names, unnamed = load_imprint_names() + if unnamed: + print( + f"WARNING: the imprint prints {len(unnamed)} authors as their GitHub ID, for want of a name " + f"in {PRINT_YML} (authors.names): {', '.join(unnamed)}", + file=sys.stderr, + ) + inputs["authors"] = ", ".join(names) for key, value in inputs.items(): command += ["--input", f"{key}={value}"] if print_edition and unpadded_pages(command, typ_path) % 2: diff --git a/tools/pdf/authors.yml b/tools/pdf/authors.yml index 7ff2db061..fb39531c7 100644 --- a/tools/pdf/authors.yml +++ b/tools/pdf/authors.yml @@ -1,49 +1,63 @@ --- # Generated by `dec-tool pdf-authors` from the GitHub contributors of -# eccenca/documentation.eccenca.com. Do not edit - run `task pdf:authors`. +# eccenca/documentation.eccenca.com and the names their profiles show. +# Do not edit - run `task pdf:authors`. Names and exclusions: tools/pdf/print.yml. repository: eccenca/documentation.eccenca.com authors: - id: rpietzsch + name: null commits: 639 - id: seebi + name: Sebastian Tramp commits: 532 - id: irangareddy + name: Ranga Reddy commits: 66 - id: muddymudskipper + name: Thomas commits: 62 - id: mgns + name: Magnus commits: 61 - id: sobo + name: Thomas Telleis commits: 53 - id: annamakor + name: null commits: 29 - - id: msaipraneeth - commits: 25 - - id: saradaimi786 - commits: 22 - id: louiswesterheide + name: Louis commits: 20 - id: robertisele + name: Robert Isele commits: 18 - id: spl0tt + name: Sebastian Plott commits: 12 - id: white-gecko + name: Natanael Arndt commits: 11 - id: BorderCloud + name: Karima Rafes commits: 10 - id: haschek + name: Michael Haschke commits: 10 - id: MaximilianWenzel + name: null commits: 9 - id: tomatophantastico + name: Jörg Unbehauen commits: 7 - id: adelahaye-ecc + name: null commits: 5 - id: dgrtner-ecc + name: null commits: 1 - id: looooph + name: Istvan Mocsy commits: 1 - id: peterfreytag - commits: 1 - - id: pkgut + name: Peter Freytag commits: 1 diff --git a/tools/pdf/print.yml b/tools/pdf/print.yml index 3b6c17c5a..20c0b6e40 100644 --- a/tools/pdf/print.yml +++ b/tools/pdf/print.yml @@ -1,6 +1,7 @@ --- # The print edition of the PDF: `task pdf:print`, see tasks/spec.md. -# tools/build_pdf.py reads `sections`; tools/pdf/style.typ reads the rest. +# tools/build_pdf.py reads `sections`, tools/pdf_authors.py `authors`, and +# tools/pdf/style.typ the rest. # Printed on the title page and in the imprint. publisher: @@ -10,6 +11,41 @@ publisher: - 04109 Leipzig - Germany +# The imprint's authors. `task pdf:authors` writes tools/pdf/authors.yml from +# the GitHub contributors, most commits first, with the name each GitHub +# profile shows. The imprint prints the name given here, else the profile's, +# else the GitHub ID - the build warns about each author it prints as an ID. +authors: + # GitHub ID: the name to print - for a profile that shows no name, or to add + # a title. IDs match case-insensitively. `task pdf:authors` adds each new + # author without a name and the profile's name in a comment; an entry left + # empty prints the profile's name. + names: + rpietzsch: René Pietzsch + seebi: Dr. Sebastian Tramp + irangareddy: Ranga Reddy + muddymudskipper: Dr. Thomas Wilmering + mgns: Magnus Knuth + sobo: Thomas Telleis + annamakor: Annamária Koren + msaipraneeth: # GitHub profile: Saipraneeth + saradaimi786: # GitHub profile: syeda sara daimi + louiswesterheide: Louis Westerheide + robertisele: Dr. Robert Isele + spl0tt: Sebastian Plott + white-gecko: Dr. Natanael Arndt + BorderCloud: Dr. Karima Rafes + haschek: Michael Haschke + MaximilianWenzel: Maximilian Wenzel + tomatophantastico: Jörg Unbehauen + adelahaye-ecc: Alexis Delahaye + dgrtner-ecc: Denis Gärtner + looooph: Istvan Mocsy + peterfreytag: Peter Freytag + pkgut: # no name on the GitHub profile + # GitHub IDs never to list; `task pdf:authors` does not look up their names. + exclude: [msaipraneeth, saradaimi786, pkgut] + # The weight of the body text: light, as on screen, or regular, which prints # darker in black and white. body-weight: light diff --git a/tools/pdf/style.typ b/tools/pdf/style.typ index 03e07897c..9737b831b 100644 --- a/tools/pdf/style.typ +++ b/tools/pdf/style.typ @@ -266,19 +266,19 @@ // DEVIATION (print): the back of the title page is the imprint - edition, // publisher, authors, licence and the online edition - and it carries the // edition stamp the screen PDF prints in its header. tools/pdf/print.yml holds -// the publisher and the section modes, tools/pdf/authors.yml the authors. -#let imprint(title: "", context-line: "", subtitle: "", generated: "", site-url: "", copyright: "") = { +// the publisher and the section modes. The authors arrive as an input: the build +// applies the names and exclusions of print.yml to tools/pdf/authors.yml. +#let imprint(title: "", context-line: "", subtitle: "", generated: "", site-url: "", copyright: "", authors: "") = { let config = yaml("print.yml") let mode(section) = if type(section) == dictionary { section.at("mode", default: "full") } else { section } let shortened = config.at("sections", default: (:)).values().any(section => mode(section) != "full") - let authors = yaml("authors.yml").authors.map(author => author.id) set text(size: size-small, fill: ec-slate) set par(justify: false, leading: 0.6em, spacing: 1.2em) let entry(label, body) = block(below: 1.3em, [#text(weight: "bold", label) \ #body]) v(1fr) entry(title)[#context-line, #subtitle \ Print edition, generated #generated] entry("Publisher")[#config.publisher.name \ #config.publisher.address.join(linebreak())] - entry("Authors")[The contributors to the documentation by their GitHub IDs, most commits first: #authors.join(", ").] + entry("Authors")[The contributors to the documentation, most commits first: #authors.] // The URL is a string: written as markup, Typst would turn it into a link. entry("Licence")[ This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License, @@ -306,6 +306,7 @@ version: "", site-url: "", copyright: "", + authors: "", body, ) = { set document( @@ -640,7 +641,7 @@ if print-edition { imprint( title: title, context-line: context-line, subtitle: subtitle, - generated: generated, site-url: site-url, copyright: copyright, + generated: generated, site-url: site-url, copyright: copyright, authors: authors, ) pagebreak() } diff --git a/tools/pdf/template.typ b/tools/pdf/template.typ index d4ebd3a93..af667b6d2 100644 --- a/tools/pdf/template.typ +++ b/tools/pdf/template.typ @@ -20,6 +20,7 @@ $endif$ version: sys.inputs.at("version"), site-url: sys.inputs.at("site-url"), copyright: sys.inputs.at("copyright"), + authors: sys.inputs.at("authors", default: ""), ) $body$ diff --git a/tools/pdf_authors.py b/tools/pdf_authors.py index 0e72826b3..176fd9e23 100644 --- a/tools/pdf_authors.py +++ b/tools/pdf_authors.py @@ -1,47 +1,267 @@ """Write the author list the imprint of the print edition prints. The authors are the GitHub accounts that contributed to the documentation -repository, most commits first. Anonymous contributions - commits whose e-mail -maps to no GitHub account - bot accounts and software agents are not listed. +repository, most commits first, each with the name its GitHub profile shows. +Anonymous contributions - commits whose e-mail maps to no GitHub account - bot +accounts, software agents and the IDs excluded in tools/pdf/print.yml are not +listed, and the names of excluded IDs are never looked up. The list is committed as tools/pdf/authors.yml, so a PDF build stays offline -and reproducible, and a changed list shows up in review. Invoked by -`task pdf:authors`; GITHUB_TOKEN or GH_TOKEN is used when set. +and reproducible, and a changed list shows up in review. The hand-maintained +`authors` section of tools/pdf/print.yml names authors whose profile shows no +name or who print with a title, and excludes IDs; the build applies it to the +committed list (`load_imprint_names`). Each run adds the authors that section +does not list yet, without a name, so every name to maintain is in one place +(`prefill_names`). Invoked by `task pdf:authors`. Requests use GITHUB_TOKEN or +GH_TOKEN, else the token of a logged-in GitHub CLI: without a token GitHub +allows 60 requests an hour, and a run takes one per author. """ from __future__ import annotations import json import os import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.parse import urllib.request +from dataclasses import dataclass, field from pathlib import Path +from typing import Callable import click import yaml REPOSITORY = "eccenca/documentation.eccenca.com" AUTHORS_YML = Path("tools/pdf/authors.yml") +# The hand-maintained names and exclusions: `authors` in the print configuration. +PRINT_YML = Path("tools/pdf/print.yml") API = "https://api.github.com" # Agents commit under ordinary accounts of type User, so they are named here. AGENT = re.compile(r"claude|codex", re.IGNORECASE) -def select_authors(contributors: list[dict]) -> list[dict]: +@dataclass(frozen=True) +class AuthorRules: + """The hand-maintained part of the author list, keyed by GitHub ID in lower case. + + `names` holds the name an author prints with - one the GitHub profile does + not show, or one with a title. `exclude` holds the IDs that are never listed. + """ + + names: dict[str, str] = field(default_factory=dict) + exclude: frozenset[str] = frozenset() + + def excludes(self, login: str) -> bool: + return login.casefold() in self.exclude + + def name(self, login: str) -> str | None: + return self.names.get(login.casefold()) + + +def load_author_rules(path: Path = PRINT_YML) -> AuthorRules: + """The `authors` section of the print configuration; without one, nothing is renamed or excluded.""" + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + section = data.get("authors") or {} + if not isinstance(section, dict): + raise click.ClickException(f"{path}: authors holds `names` and `exclude`") + names = section.get("names") or {} + exclude = section.get("exclude") or [] + # A name left empty is not given: the imprint prints the profile's. + if not isinstance(names, dict) or not all( + isinstance(login, str) and (name is None or isinstance(name, str)) for login, name in names.items() + ): + raise click.ClickException(f"{path}: authors.names maps GitHub IDs to the names they print with") + if not isinstance(exclude, list) or not all(isinstance(login, str) for login in exclude): + raise click.ClickException(f"{path}: authors.exclude is a list of GitHub IDs") + return AuthorRules( + names={login.casefold(): name.strip() for login, name in names.items() if name and name.strip()}, + exclude=frozenset(login.casefold() for login in exclude), + ) + + +def select_authors(contributors: list[dict], rules: AuthorRules = AuthorRules()) -> list[dict]: """The contributors the imprint lists, in its order: most commits first, ties by ID, case-insensitive.""" people = [ c for c in contributors - if c.get("type") == "User" and c.get("login") and not AGENT.search(c["login"]) + if c.get("type") == "User" + and c.get("login") + and not AGENT.search(c["login"]) + and not rules.excludes(c["login"]) ] people.sort(key=lambda c: (-int(c["contributions"]), c["login"].casefold())) return [{"id": c["login"], "commits": int(c["contributions"])} for c in people] +def add_names(authors: list[dict], profile_name: Callable[[str], str | None]) -> list[dict]: + """Each author with the name its GitHub profile shows, or None when it shows none.""" + return [ + {"id": author["id"], "name": (profile_name(author["id"]) or "").strip() or None, "commits": author["commits"]} + for author in authors + ] + + +def imprint_names(authors: list[dict], rules: AuthorRules) -> tuple[list[str], list[str]]: + """The names the imprint prints, in order, and the IDs that print as themselves for want of a name. + + A name in the rules comes first, then the profile's. Excluded IDs are left + out, also when the committed list predates their exclusion. + """ + names: list[str] = [] + unnamed: list[str] = [] + for author in authors: + login = str(author["id"]) + if rules.excludes(login): + continue + name = rules.name(login) or author.get("name") + if not name: + unnamed.append(login) + names.append(name or login) + return names, unnamed + + +def load_imprint_names( + authors_yml: Path = AUTHORS_YML, print_yml: Path = PRINT_YML +) -> tuple[list[str], list[str]]: + """`imprint_names` for the committed author list and the print configuration.""" + data = yaml.safe_load(authors_yml.read_text(encoding="utf-8")) or {} + return imprint_names(data.get("authors") or [], load_author_rules(print_yml)) + + +def names_entry(login: str, profile_name: str | None) -> str: + """An `authors.names` line that gives no name, with the profile's name in its comment.""" + # Dumped, so a login YAML would read as something else - `null`, `yes` - is quoted. + key = yaml.safe_dump({login: None}, allow_unicode=True).rstrip("\n").removesuffix(" null") + note = f"GitHub profile: {' '.join(profile_name.split())}" if profile_name else "no name on the GitHub profile" + return f" {key} # {note}" + + +def prefill_names(text: str, authors: list[dict]) -> tuple[str, list[str]]: + """The print configuration with an `authors.names` entry for each author it neither lists nor excludes. + + Returns the text and the IDs added. An added entry gives no name, so the + imprint keeps printing the profile's until someone fills one in. The file is + edited as text, so its comments stay, and read back to make sure the new + entries are all that changed. + """ + before = yaml.safe_load(text) or {} + section = before.get("authors") or {} + listed = {str(login).casefold(): name for login, name in (section.get("names") or {}).items()} + known = set(listed) | {str(login).casefold() for login in (section.get("exclude") or [])} + missing = [author for author in authors if str(author["id"]).casefold() not in known] + if not missing: + return text, [] + ids = [str(author["id"]) for author in missing] + entries = [names_entry(login, author.get("name")) for login, author in zip(ids, missing)] + + lines = text.splitlines() + top = next((i for i, line in enumerate(lines) if re.match(r"authors:\s*(#.*)?$", line)), None) + if top is None: + lines += ["", "authors:", " names:", *entries, " exclude: []"] + else: + # The section runs until the next line that starts at the margin. + end = next( + (i for i in range(top + 1, len(lines)) if lines[i].strip() and not lines[i].startswith(" ")), + len(lines), + ) + names = next((i for i in range(top + 1, end) if re.match(r" names:", lines[i])), None) + if names is None: + lines[top + 1:top + 1] = [" names:", *entries] + else: + lines[names] = re.sub(r":\s*\{\s*\}", ":", lines[names], count=1) + last = names + for i in range(names + 1, end): + if not lines[i].strip(): + continue + if not lines[i].startswith(" "): + break + last = i + lines[last + 1:last + 1] = entries + result = "\n".join(lines) + "\n" + + after = yaml.safe_load(result) or {} + after_section = after.get("authors") or {} + after_names = {str(login).casefold(): name for login, name in (after_section.get("names") or {}).items()} + others = lambda data: {key: value for key, value in data.items() if key != "authors"} + if ( + after_names != {**listed, **{login.casefold(): None for login in ids}} + or (after_section.get("exclude") or []) != (section.get("exclude") or []) + or others(after) != others(before) + ): + raise click.ClickException( + f"cannot add {', '.join(ids)} to authors.names of the print configuration - add them by hand" + ) + return result, ids + + def next_page(link_header: str) -> str | None: """The `rel="next"` URL of a GitHub Link header, if there is one.""" match = re.search(r'<([^>]+)>;\s*rel="next"', link_header) return match.group(1) if match else None +TOKEN_HINT = "set GITHUB_TOKEN or GH_TOKEN, or log in with `gh auth login`" + + +def github_token() -> tuple[str | None, str | None]: + """The token for the GitHub API, and where it comes from. + + GITHUB_TOKEN or GH_TOKEN when set, else the token the GitHub CLI is logged in + with. Without either, requests go out unauthenticated. + """ + for variable in ("GITHUB_TOKEN", "GH_TOKEN"): + if os.environ.get(variable): + return os.environ[variable], variable + gh = shutil.which("gh") + if gh is None: + return None, None + try: + result = subprocess.run([gh, "auth", "token"], capture_output=True, text=True, timeout=10, check=False) + except (OSError, subprocess.SubprocessError): + return None, None + token = result.stdout.strip() if result.returncode == 0 else "" + return (token, "the GitHub CLI") if token else (None, None) + + +def api_failure(error: urllib.error.HTTPError, url: str, authenticated: bool) -> str: + """What a failed GitHub API request means, and what to do about it.""" + headers = error.headers or {} + reset, retry = headers.get("X-RateLimit-Reset"), headers.get("Retry-After") + limited = headers.get("X-RateLimit-Remaining") == "0" or retry or "rate limit" in str(error.reason).lower() + if error.code in (403, 429) and limited: + if reset and str(reset).isdigit(): + when = f"; it resets at {time.strftime('%H:%M', time.localtime(int(reset)))}" + elif retry and str(retry).isdigit(): + when = f"; retry in {retry} seconds" + else: + when = "" + advice = "" if authenticated else f" - {TOKEN_HINT} for 5,000 requests an hour" + return f"the GitHub API rate limit is exceeded{when}{advice}" + if error.code == 401: + return "GitHub rejected the token (HTTP 401) - check GITHUB_TOKEN, GH_TOKEN or `gh auth status`" + return f"the GitHub API answered HTTP {error.code} {error.reason} for {url}" + + +def get_json(url: str, token: str | None) -> tuple[object, str | None]: + """A GitHub API response, and the URL of its next page. + + A failed request ends the command with what it means, not with a traceback. + """ + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + if token: + request.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response), next_page(response.headers.get("Link", "")) + except urllib.error.HTTPError as error: + raise click.ClickException(api_failure(error, url, token is not None)) from error + except (urllib.error.URLError, TimeoutError) as error: + raise click.ClickException(f"cannot reach the GitHub API ({getattr(error, 'reason', error)})") from error + + def fetch_contributors(repository: str, token: str | None) -> list[dict]: """Every contributor of a repository, following the API's pagination. @@ -50,15 +270,17 @@ def fetch_contributors(repository: str, token: str | None) -> list[dict]: url: str | None = f"{API}/repos/{repository}/contributors?per_page=100" contributors: list[dict] = [] while url: - request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) - if token: - request.add_header("Authorization", f"Bearer {token}") - with urllib.request.urlopen(request, timeout=30) as response: - contributors.extend(json.load(response)) - url = next_page(response.headers.get("Link", "")) + page, url = get_json(url, token) + contributors.extend(page) return contributors +def fetch_profile_name(login: str, token: str | None) -> str | None: + """The name a GitHub account's public profile shows, if it shows one.""" + profile, _ = get_json(f"{API}/users/{urllib.parse.quote(login)}", token) + return profile.get("name") + + class IndentedDumper(yaml.SafeDumper): """Indents list items under their key, as the repository's yamllint rules require.""" @@ -71,7 +293,8 @@ def authors_yaml(repository: str, authors: list[dict]) -> str: header = ( "---\n" "# Generated by `dec-tool pdf-authors` from the GitHub contributors of\n" - f"# {repository}. Do not edit - run `task pdf:authors`.\n" + f"# {repository} and the names their profiles show.\n" + "# Do not edit - run `task pdf:authors`. Names and exclusions: tools/pdf/print.yml.\n" ) body = yaml.dump( {"repository": repository, "authors": authors}, @@ -95,10 +318,32 @@ def authors_yaml(repository: str, authors: list[dict]) -> str: show_default=True, ) def pdf_authors(repository: str, output_file: str) -> None: - """Write the imprint's author list from the repository's GitHub contributors.""" - token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") - authors = select_authors(fetch_contributors(repository, token)) + """Write the imprint's author list from the repository's GitHub contributors and their profile names.""" + token, source = github_token() + if source: + # Flushed, so the line comes before an error message also when the output is piped. + print(f"Using the GitHub token from {source}", flush=True) + else: + print( + "WARNING: no GitHub token - GitHub allows 60 requests an hour without one, and this run takes one " + f"per author; {TOKEN_HINT}", + file=sys.stderr, + ) + rules = load_author_rules() + authors = select_authors(fetch_contributors(repository, token), rules) if not authors: raise click.ClickException(f"no contributors found for {repository}") + authors = add_names(authors, lambda login: fetch_profile_name(login, token)) Path(output_file).write_text(authors_yaml(repository, authors), encoding="utf-8") print(f"{len(authors)} authors written to {output_file}") + prefilled, added = prefill_names(PRINT_YML.read_text(encoding="utf-8"), authors) + if added: + PRINT_YML.write_text(prefilled, encoding="utf-8") + print(f"{len(added)} authors added to authors.names in {PRINT_YML}: {', '.join(added)}") + _, unnamed = imprint_names(authors, load_author_rules()) + if unnamed: + print( + f"WARNING: {len(unnamed)} authors show no name on their GitHub profile and have none in " + f"{PRINT_YML} (authors.names), so the imprint prints their ID: {', '.join(unnamed)}", + file=sys.stderr, + ) From 5923313265bbad01e70f5c4e84934e47462c1f43 Mon Sep 17 00:00:00 2001 From: Rene Pietzsch Date: Tue, 15 Sep 2026 14:55:34 +0200 Subject: [PATCH 03/15] leave content out of the print edition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The print edition had no way to leave out single pages or parts of a page, and omit left a title and a note behind. - print.yml keys may name a page; omit drops pages and subtrees without a trace - the class print-exclude replaces a part of a page with a note pointing to the page online; the site and screen PDF are unchanged - omit the cmem-client API and the OSINT tutorial, and the INSERT listing of the Snowflake tutorial: 966 to 666 pages - spec §10 and backlog P18, authoring note in repo-conventions.md --- .claude/docs-guidelines/repo-conventions.md | 18 +++ docs/build/snowflake-tutorial/index.md | 2 +- tasks/backlog.md | 59 +++++++- tasks/spec.md | 145 +++++++++++++++++++- tests/test_build_pdf.py | 4 +- tests/test_build_pdf_print.py | 129 ++++++++++++++--- tools/build_pdf.py | 142 +++++++++++++------ tools/pdf/print.yml | 22 +-- 8 files changed, 447 insertions(+), 74 deletions(-) diff --git a/.claude/docs-guidelines/repo-conventions.md b/.claude/docs-guidelines/repo-conventions.md index b142e23db..f23ca681b 100644 --- a/.claude/docs-guidelines/repo-conventions.md +++ b/.claude/docs-guidelines/repo-conventions.md @@ -204,6 +204,24 @@ Known drift to fix when touching a page: 32 `shell-session` blocks in hand-writt numbering restarts at 1 in the rendered page. Check the rendered result for any step list you touch. - `sane_lists` is enabled: an unordered item does not continue an ordered list. +## Leaving content out of the print edition + +The print edition of the PDF (`task pdf:print`, `tasks/spec.md` §10) can leave out content that only makes sense online. + +- A subtree or a single page: an `omit` entry under `sections` in `tools/pdf/print.yml`, keyed by its `docs/` path. + The page itself carries no marker, so this works for generated pages too. +- A part of a page: the class `print-exclude` on the block. The print edition prints a note in its place that points + to the page online; the site and the screen PDF show the part unchanged. + +| Block | Markup | +| --- | --- | +| code block | `sql { .print-exclude }` right after the backticks of the opening fence | +| admonition or collapsible block | `??? example print-exclude "INSERT query"` | +| paragraph | `{ .print-exclude }` on the line after the paragraph | +| several blocks | ``, with blank lines around the content | + +A generated page cannot carry the class: the next generator run removes it. + ## Editorial decisions beyond the style guide `style-guide.md` is a transcription of the Confluence page and is not extended locally. diff --git a/docs/build/snowflake-tutorial/index.md b/docs/build/snowflake-tutorial/index.md index 3f898c32c..43d2d4c84 100644 --- a/docs/build/snowflake-tutorial/index.md +++ b/docs/build/snowflake-tutorial/index.md @@ -89,7 +89,7 @@ Here you can populate some test data with the following SQL query: ??? example "INSERT query" - ```sql + ```sql { .print-exclude } INSERT INTO product(product_id,product_name,height,width,depth,weigth,product_manager,price) VALUES ('I241-8776317','Strain Compensator',12,68,15,8,'Baldwin.Dirksen@company.org','0,50 EUR') ,('D215-3449390','Gauge Crystal',77,58,19,15,'Wanja.Hoffmann@company.org','2,00 EUR') diff --git a/tasks/backlog.md b/tasks/backlog.md index 33c3ed484..9223161ae 100644 --- a/tasks/backlog.md +++ b/tasks/backlog.md @@ -12,7 +12,8 @@ Every task names how it is verified. A task is not done until that verification Spec §4, D1-D11: BoD, A4, black and white on 80 g, no ISBN, authors by name (first GitHub IDs, revised 2026-09-15), separate screen and print editions, section modes with A.3 and Release Notes as lists, page references and URL footnotes, -no logo or version on text pages, 10 pt body, optional Ghostscript normalization. +no logo or version on text pages, 10 pt body, optional Ghostscript normalization. D12-D14, decided +2026-09-15: excluding content from the print edition (spec §10), implemented by P18. --- @@ -342,6 +343,61 @@ so it is not forgotten; it has its own spec. --- +## P18 - Content exclusion - **done** + +Spec §10, D12-D14: leave subtrees, pages and parts of a page out of the print edition. + +- `sections` keys in `tools/pdf/print.yml` may name a page (`.md`), which accepts `omit` only; a nested + key, or one that matches no page in `nav.yml`, fails the build +- `omit` drops a page or a subtree without a title or a note. For directories this revises P8, which + left the section's title and a note naming the online edition +- dropping a section's index page keeps its other pages under a heading with the navigation title +- in the print edition, elements with the class `print-exclude` are removed from the page's article + before ids, links and headings are processed. A note takes their place: "This print edition leaves + out a part of this page. The online edition has the full details:" and the page's online address, + with the anchor of the heading before the part; consecutive parts share one note. The build logs the + parts removed per page +- the site and the screen PDF ignore the class +- documentation: the `sections` comment in `print.yml`, and a line for authors in + `.claude/docs-guidelines/repo-conventions.md` on the class, which generated pages cannot carry +- configuration: `develop/cmem-client-api/: omit`, `build/tutorial-how-to-link-ids-to-osint/: omit` (all + 7 pages), and `{ .sql .print-exclude }` on the SQL code block of the Snowflake tutorial - lines 92-1094 + of `docs/build/snowflake-tutorial/index.md` on 2026-09-15, inside `??? example "INSERT query"`, which + keeps its title + +**Verify:** unit tests for page keys, the mode check, nesting, a dropped index page, the removal with +one note per run of parts and its address, and no removal in the screen edition. `task pdf:print` with +the three examples: G.5 and A.16 leave no heading or note, the `INSERT query` block of +the Snowflake tutorial holds the note instead of the listing, and the page count drops by about 300 +(spec §10). `task pdf`: page count and text unchanged apart +from the stamp. `task check` passes. +**Est:** small to medium. **Depends on:** P8, P9. + +**Done 2026-09-15:** + +- `SectionRule` knows page rules (`page`, `matches`). `load_section_rules` accepts page keys with `omit` + only and rejects nested keys. +- `omit_section` drops a page or a subtree without a trace. An index page omitted on its own leaves its + navigation title as a heading. +- `exclude_parts` replaces each run of `.print-exclude` elements with the note, whose address carries the + anchor of the heading before the part. `merge_pages` returns the parts left out per page, which the + build logs. List tables skip marked elements as well. +- `print.yml` omits `develop/cmem-client-api/` (75 pages) and `build/tutorial-how-to-link-ids-to-osint/` + (7 pages). The SQL block of the Snowflake tutorial carries `sql { .print-exclude }`. Authors find the + markup in `.claude/docs-guidelines/repo-conventions.md`. +- Measured: + - The print edition drops from 966 to 666 pages, the Snowflake tutorial from 46 to 22. + - No bookmark is left for G.5 or A.16. + - The note stands inside the `INSERT query` block (p. 120), with the anchor + `#1-create-a-database-in-snowflake`. + - No page runs into the footer and no line runs past the text column. + - The screen PDF keeps 1683 pages, its text unchanged and the listing in it. + - 134 unit tests pass. + - The `task check` steps pass; yamllint passes on the tracked files, while the untracked `scratch/` + folder still fails it. + +--- + ## Sequencing ```text @@ -354,6 +410,7 @@ P1 ──> P2 ──> P3 ──> P4 ──> P5 ──> P7 └──────────────┼──> P13 <────┘ └──> P14 ──> P16 P17 after P8 and P14 +P18 after P8 and P9 ``` P1, P6, P8, P10, P11 and P12 can start now. diff --git a/tasks/spec.md b/tasks/spec.md index 9d5e3a5d8..48ab3266b 100644 --- a/tasks/spec.md +++ b/tasks/spec.md @@ -2,7 +2,8 @@ **Status:** accepted 2026-09-14 - all decisions made (§4). Backlog P0-P12 implemented 2026-09-15 (`task pdf:print`, 962 pages); P13-P17 open. The backlog's "Done" notes record where the implementation -refines this spec. +refines this spec. §10, excluding content from the print edition, was decided on 2026-09-15 (D12-D14) +and implemented the same day (backlog P18): the print edition has 666 pages. **Branch:** `feature/print-on-demand`, based on `main` at `c20d74b94` (PDF export merged). **Goal:** a *book block* - the interior file of a printed, perfect-bound book - built next to the screen PDF, which BoD accepts without rework. @@ -157,7 +158,7 @@ are left out of the book block - Ghostscript refuses PDF/X output while a page c ## 4. Decisions -All made 2026-09-14. +D1-D11 made 2026-09-14; D12-D14 made 2026-09-15 (§10). | # | Question | Decision | | :-- | :-- | :-- | @@ -172,6 +173,9 @@ All made 2026-09-14. | D9 | Logo and version in the running header | title page and imprint only (R1) | | D10 | Body type size | keep 10 pt and tighten the spacing (§5). A smaller body, 9 pt or even 8 pt, only if the page limit is still exceeded - not needed for the default configuration | | D11 | Normalization | an **optional** Ghostscript pass after Typst: PDF/X-4, CMYK, all images at 300 dpi (§7) | +| D12 | Subtrees and pages | path rules in `tools/pdf/print.yml`: a `sections` key may name a page, which accepts `omit` only; no front matter property, no `.pdfexclude` (§10) | +| D13 | Parts of a page | the class `print-exclude`, effective in the print edition only; the site and the screen PDF are unchanged (§10) | +| D14 | What stands in for excluded content | pages and subtrees: nothing, also for a directory's `omit` (revises §5); parts of a page: one note per run of parts, pointing to the page in the online edition for the full details (§10) | ## 5. Page budget @@ -199,8 +203,9 @@ measure its page cost before deciding. ### Section modes -`full` prints the section as today. `omit` leaves it out; the part contents point to the online -edition. `list` reduces it to a two-column table: +`full` prints the section as today. `omit` leaves it out without a trace in the text (D14, revised +2026-09-15); the imprint names the online edition as the complete reference. `list` reduces it to a +two-column table: | Section | `list` renders | Measured | | :-- | :-- | :-- | @@ -369,6 +374,9 @@ the same task, for checking the palette (P11). - every image at 300 ppi or more at its printed size, or listed and accepted - with normalization: the file declares PDF/X-4, carries a FOGRA39 output intent, CMYK images only, no image above 300 ppi +- a page key in `print.yml` drops that page; `omit` leaves no title and no note for a page or a section +- in the print edition, each run of `.print-exclude` parts is replaced by one note with the page's + online address; the site and the screen PDF show the parts unchanged - `task check` and `task test:unit` pass ## 9. Out of scope @@ -377,3 +385,132 @@ the same task, for checking the palette (P11). - ISBN and retail distribution (D3, maybe later) - EPUB or other e-book formats - translations + +## 10. Excluding content from the print edition + +**Status:** decided and implemented 2026-09-15 (D12-D14 in §4, backlog P18). With the three examples +configured, the print edition has 666 pages instead of 966. + +The section modes (§5) shorten whole reference sections. Some content is unfit for paper at a finer +grain: + +| Grain | Example | Print pages | +| :-- | :-- | --: | +| subtree | G.5 cmem-client: Python API, `develop/cmem-client-api/` - 75 pages, 74 of them generated | 207 | +| subtree | A.16 How to link IDS to OSINT, `build/tutorial-how-to-link-ids-to-osint/` - all 7 pages (decided 2026-09-15) | 68 | +| page | none configured yet; a page key leaves out a single page when one needs it | - | +| part of a page | A.14 Connect to Snowflake: the SQL code block of the collapsed `??? example "INSERT query"` block, lines 92-1094 of `docs/build/snowflake-tutorial/index.md` on 2026-09-15 - about 1,000 lines (decided 2026-09-15) | 25 (pp. 122-146) | + +Measured in the print edition of 2026-09-15 (966 pages). Excluding all three saves about 300 pages. + +### Constraints + +- The build reads the rendered site, not the Markdown. A marker for a part of a page must survive + Zensical's rendering; a rule for a page or subtree must be decidable from the paths in `nav.yml`. +- Generated pages are rewritten wholesale: `task update:cmemc` and `task update:cmem-client-api` run + `rm -rf /*`, and `dec-tool update-di-reference` deletes its whole tree. Whatever is stored in a + generated page, or next to it, is lost on the next run. +- The site and the screen PDF stay as they are (D6). + +### Options + +| Option | Subtree | Page | Part | Verdict | +| :-- | :-- | :-- | :-- | :-- | +| front matter property, e.g. `print: exclude` | each page marked | yes | no | rejected: lost on generated pages; a subtree means marking every page; no single place shows what the book leaves out | +| `.pdfexclude` file in gitignore syntax | yes | yes | no | rejected: a second configuration next to `print.yml`, with a new format and a discovery rule, spread over the tree; deleted with a generated directory | +| path rules in `tools/pdf/print.yml` | yes - `omit` exists | yes, once a key may name a page | no | **chosen** for subtrees and pages (D12) | +| comment pair `` … `` | no | no | yes | works, not proposed: the pair must stay siblings, a single block costs two extra lines, and a misspelt marker is ignored without a trace | +| class `print-exclude` on the rendered element | no | no | yes | **chosen** for parts of a page (D13) | +| CSS selectors per page in `print.yml` | no | no | yes | rejected: ties the configuration to theme markup | +| `exclude_docs` or `not_in_nav` in `mkdocs.yml` | yes | yes | no | rejected: removes the pages from the site as well | + +Verified on 2026-09-15 in a scratch project with Zensical 0.0.62 and the Markdown extensions of +`mkdocs.yml`: + +- **Comment pairs:** they arrive in the HTML as unescaped comments. Both comments of a pair stay + side by side, at top level and inside an admonition, a content tab and a list item. +- **The class:** it lands on the element in every spelling listed below. Removing the `.print-exclude` + elements leaves exactly the unmarked content. +- **Front matter:** an unknown front matter key builds without a warning. + +### Design + +#### Subtrees and pages: path rules in `print.yml` (D12) + +A key under `sections` names a docs directory, ending in `/` as today, or a single page, ending in +`.md`: + +```yaml +sections: + develop/cmem-client-api/: omit + build/tutorial-how-to-link-ids-to-osint/: omit + # a single page: /index.md: omit +``` + +- A directory key keeps its three modes (§5). `omit` drops the section without a title or a note (D14). + The first implementation (P8) left both; the imprint names the online edition as the complete + reference instead. +- A page key accepts `omit` only; any other mode fails the build. The page is dropped without a note. +- Dropping the index page of a section keeps its other pages, under a heading with the section's + navigation title - the shape Release Notes already has (§3). +- Keys do not nest: a key inside a directory that another key shortens fails the build. +- A key that matches no page in `nav.yml` fails the build, as today. +- A link to a dropped page prints the page's online address in a footnote, like any link that leaves + the book (R4). Within a section that `list` or `omit` shortens, it prints as text, as today. + +#### Parts of a page: the class `print-exclude` (D13) + +One class, spelled the way the element takes it: + +````markdown +??? example print-exclude "INSERT query" + + ```sql + INSERT INTO product(...) VALUES ... + ``` + +```{ .sql .print-exclude } +SELECT ... +``` + +A paragraph the print edition leaves out. +{ .print-exclude } + + +```` + +- The print edition removes every element with the class from a page's article, before ids, links and + headings are processed. The class has no effect on the site and the screen PDF. +- A heading inside a removed part leaves the numbering and the contents. Links to it print as text, the + rule for any link without a target. +- The build logs how many parts it removed per page, so a marker that no longer matches shows. +- A note takes the place of a removed part (D14): *This print edition leaves out a part of this page. + The online edition has the full details:* followed by the page's online address, with the anchor of + the heading the part belongs to. Consecutive removed parts share one note. +- In the Snowflake tutorial the class goes on the SQL code block, as `{ .sql .print-exclude }` on its + opening fence: exactly lines 92-1094 are removed. The `??? example "INSERT query"` block around it keeps + its title and holds the note. +- A generated page cannot carry the class: its generator has to emit it, or a path rule drops the page. +- The name follows the edition. The site does not style the class; a later `@media print` rule could + use it for printing from the browser. + +### Implementation outline + +- **Rules:** `load_section_rules` accepts `.md` keys, rejects any mode but `omit` for them and rejects + nested keys. +- **Page `omit`:** `apply_section_rules` drops the page's entry. For an index page it inserts a heading + entry with the navigation title, which `NavEntry.title` already carries. +- **Parts:** in the print edition, `page_article` replaces the `.print-exclude` elements with the note - + one per run of consecutive parts - and counts them. +- **`omit` for directories:** no title and no note any more. `test_omit_drops_the_pages_and_leaves_a_heading_with_a_note` + and `test_merge_renders_an_omitted_section_as_its_title_and_a_note` change with it. +- **Tests:** `tests/test_build_pdf_print.py` gets page keys, the mode check, nesting, a dropped index + page, and the removal with its note and online address in the print edition only. +- **Documentation:** the `sections` comment in `print.yml`, and for authors a line in + `.claude/docs-guidelines/repo-conventions.md` on the class, including that generated pages cannot + carry it. +- **Effort:** small to medium; backlog P18. diff --git a/tests/test_build_pdf.py b/tests/test_build_pdf.py index b5229759f..38eb1eb6d 100644 --- a/tests/test_build_pdf.py +++ b/tests/test_build_pdf.py @@ -74,7 +74,7 @@ def test_merge_breaks_chapters_adds_section_headings_and_drops_web_chrome(tmp_pa NavEntry(depth=1, md="release-notes/v1/index.md"), NavEntry(depth=1, md="release-notes/v2/index.md"), ] - doc, missing = merge_pages(entries, site, "https://example.org/latest/") + doc, missing, _ = merge_pages(entries, site, "https://example.org/latest/") assert missing == ["release-notes/v2/index.md"] assert str(doc.body) == ( '

Release Notes

' @@ -92,7 +92,7 @@ def test_part_cover_is_the_title_then_the_diagram_the_page_shows_above_it(tmp_pa '

Build

Intro

' '' ) - doc, _ = merge_pages([NavEntry(depth=0, md="build/index.md")], site, "https://example.org/latest/") + doc, _, _ = merge_pages([NavEntry(depth=0, md="build/index.md")], site, "https://example.org/latest/") assert str(doc.body) == ( '