diff --git a/.claude/docs-guidelines/repo-conventions.md b/.claude/docs-guidelines/repo-conventions.md
index b142e23db..ac38f16de 100644
--- a/.claude/docs-guidelines/repo-conventions.md
+++ b/.claude/docs-guidelines/repo-conventions.md
@@ -127,6 +127,11 @@ Do not write `index.html` stubs under `docs/`.
- `class="bordered"` on every product screenshot (436 uses in the tree).
- `width="50%"` or `width="70%"` for dialogs and modals, no width for full-screen views. Keep the width
consistent within one page.
+- A screenshot needs a width small enough for its pixels, or it prints coarse: an image of 900 pixels
+ across the full 16 cm column prints at 143 ppi. `poetry run dec-tool image-widths` lists every raster
+ image in a page that is not generated that prints below 150 ppi, and `--fix` writes the `width="NN%"`
+ that reaches it — the pixel width divided by the column and the target, rounded down. The same width
+ then governs the site.
- `.off-glb` opts an image out of the glightbox lightbox — used for inline icons and decorative images.
- File names: lowercase, hyphen-separated, descriptive (style guide 5.5).
- Delete screenshots that are no longer referenced. Orphan check for one page directory:
@@ -204,6 +209,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/.github/workflows/pdf.yml b/.github/workflows/pdf.yml
index 61c481a27..65d23083c 100644
--- a/.github/workflows/pdf.yml
+++ b/.github/workflows/pdf.yml
@@ -1,18 +1,24 @@
---
name: PDF
-# Builds the PDF of the whole documentation after every push to main, merged
-# pull requests included, and keeps it as an artifact of the workflow run.
+# Builds every PDF of the documentation after a push to main or to the
+# print-on-demand branch: the screen edition, the print edition (the book block
+# for print on demand), its PDF/X-4 copy in CMYK and the greyscale preview. The
+# run summary lists all four with their download links, so they need not be dug
+# out of the artifact section.
on:
push:
- branches: ["main"]
+ branches: ["main", "feature/print-on-demand"]
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
permissions:
contents: read
-# A newer push to main makes a running build obsolete.
+# A newer push to the same branch makes a running build obsolete; main and a
+# branch do not cancel each other.
concurrency:
- group: "pdf"
+ group: "pdf-${{ github.ref }}"
cancel-in-progress: true
env:
@@ -62,8 +68,10 @@ jobs:
- name: update packages
run: sudo apt-get update -y
+ # Poppler carries the pdffonts, pdfimages and pdftotext the preflight report
+ # reads. Ghostscript comes from a container below, not from apt.
- name: install packages
- run: sudo apt-get install -y libffi-dev libz-dev
+ run: sudo apt-get install -y libffi-dev libz-dev poppler-utils
- name: install pandoc
run: |
@@ -79,14 +87,119 @@ jobs:
| tar -xJ -C "$RUNNER_TEMP"
sudo install -m 0755 "$RUNNER_TEMP/typst-x86_64-unknown-linux-musl/typst" /usr/local/bin/typst
- - name: build pdf
+ # The ISO Coated v2 profile of the output intent is not in the repository
+ # (its licence forbids it), so it is fetched once and kept between runs.
+ - name: cache the output intent profile
+ uses: actions/cache@v4
+ with:
+ path: dist/icc
+ key: icc-iso-coated-v2-eci
+
+ # Ubuntu ships Ghostscript 10.02, where `-dPDFX=4` is still a boolean, so
+ # the 4 raises `/typecheck in --pdfmark--` and PDF/X-4 cannot be written at
+ # all. A container carries a newer one, behind a shim that runs it at the
+ # paths the build uses, so the PDFs, the pdfmark prefix and the profiles
+ # resolve inside the container exactly as they do outside. The grey output
+ # intent profile comes out of the same image.
+ - name: ghostscript from a container
+ run: |
+ printf 'FROM alpine:edge\nRUN apk add --no-cache ghostscript\n' | docker build -q -t pdfx-gs -
+ W="$GITHUB_WORKSPACE"
+ sudo tee /usr/local/bin/gs-docker >/dev/null < dist/icc/default_gray.icc
+ {
+ echo "GHOSTSCRIPT=/usr/local/bin/gs-docker"
+ echo "PDF_GRAY_PROFILE=$W/dist/icc/default_gray.icc"
+ } >> "$GITHUB_ENV"
+
+ - name: build the screen pdf
run: task pdf
- - name: upload pdf
+ - name: build the print pdfs
+ run: task pdf:print -- --normalize --gray
+
+ - name: upload the screen pdf
+ id: screen
uses: actions/upload-artifact@v4
with:
- name: documentation-eccenca-com-pdf
- path: dist/documentation-eccenca-com-*.pdf
+ name: pdf-screen
+ path: |
+ dist/documentation-eccenca-com-*.pdf
+ !dist/documentation-eccenca-com-*-print*.pdf
if-no-files-found: error
+ retention-days: 30
# A PDF is compressed already.
compression-level: 0
+
+ - name: upload the print pdf
+ id: print
+ uses: actions/upload-artifact@v4
+ with:
+ name: pdf-print
+ path: dist/documentation-eccenca-com-*-print.pdf
+ if-no-files-found: error
+ retention-days: 30
+ compression-level: 0
+
+ - name: upload the pdf/x-4 pdf
+ id: pdfx
+ uses: actions/upload-artifact@v4
+ with:
+ name: pdf-print-x4
+ path: dist/documentation-eccenca-com-*-print-x4.pdf
+ if-no-files-found: error
+ retention-days: 30
+ compression-level: 0
+
+ - name: upload the greyscale pdf
+ id: gray
+ uses: actions/upload-artifact@v4
+ with:
+ name: pdf-print-gray-x4
+ path: dist/documentation-eccenca-com-*-print-gray-x4.pdf
+ if-no-files-found: error
+ retention-days: 30
+ compression-level: 0
+
+ # The table is built in one go and printed to the log as well, so the
+ # editions and their links are readable even if the summary ever stays
+ # empty - which is how the first run of this step ended.
+ - name: list the pdfs in the run summary
+ run: |
+ # $1 is a glob; $4, when given, drops what the glob also matches - the
+ # screen edition is the one file whose name carries no `-print`.
+ describe() {
+ file="$(ls $1 2>/dev/null | grep -v -- "${4:-^$}" | head -1)"
+ if [ -z "$file" ]; then
+ printf '| %s | - | - | missing |\n' "$2"
+ return
+ fi
+ printf '| %s | %s | %s | [%s](%s) |\n' "$2" \
+ "$(pdfinfo "$file" 2>/dev/null | awk '/^Pages/ {print $2}')" \
+ "$(du -h "$file" | cut -f1)" "$(basename "$file")" "$3"
+ }
+ {
+ echo "## PDF editions of ${GITHUB_REF_NAME}"
+ echo
+ echo "| Edition | Pages | Size | Download |"
+ echo "| :------ | ----: | ---: | :------- |"
+ describe "dist/documentation-eccenca-com-*.pdf" \
+ "Screen" "${{ steps.screen.outputs.artifact-url }}" "-print"
+ describe "dist/documentation-eccenca-com-*-print.pdf" \
+ "Print, book block" "${{ steps.print.outputs.artifact-url }}"
+ describe "dist/documentation-eccenca-com-*-print-x4.pdf" \
+ "Print, PDF/X-4 in CMYK" "${{ steps.pdfx.outputs.artifact-url }}"
+ describe "dist/documentation-eccenca-com-*-print-gray-x4.pdf" \
+ "Print, greyscale preview" "${{ steps.gray.outputs.artifact-url }}"
+ echo
+ echo "The print editions carry the preflight report of \`dec-tool pdf-preflight\`;"
+ echo "a failed check fails this job."
+ } | tee -a "$GITHUB_STEP_SUMMARY"
+ echo "summary file: $GITHUB_STEP_SUMMARY, $(wc -c < "$GITHUB_STEP_SUMMARY") bytes"
diff --git a/.gitignore b/.gitignore
index e0d7d8f02..42afcc295 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ tmp
.python-version
dist/
.env
+*.sw[a-p]
+*~
diff --git a/Taskfile.yml b/Taskfile.yml
index 837b9c496..4a4b6c45a 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -69,6 +69,7 @@ tasks:
cmds:
- task: check:links
- task: check:rumdl
+ - task: check:images
- task: check:yamllint
- task: check:navigation
- task: check:output
@@ -82,6 +83,18 @@ tasks:
# CI run agree: GitHub runners have no user-level yamllint config.
- poetry run yamllint .
+ check:images:
+ desc: Fail if a screenshot prints below 150 ppi in the print edition
+ summary: |
+ Runs `dec-tool image-widths`: every raster image in a page that is not
+ generated must carry a width small enough for its pixels, or it prints
+ coarse (tasks/spec.md, §11, D18). `dec-tool image-widths --fix` writes
+ the width a listed image needs.
+ deps:
+ - install
+ cmds:
+ - poetry run dec-tool image-widths
+
check:navigation:
desc: Fail if nav.yml is out of sync with the docs/**/.pages files
deps:
@@ -200,6 +213,60 @@ 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, 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
+ screen PDF of `task pdf` is not affected.
+
+ The build lists images below 150 ppi at their printed size in
+ dist/pdf/print/low-resolution.tsv and runs the preflight report
+ (`dec-tool pdf-preflight`), failing on an error. `task pdf:print --
+ --normalize` (or PDF_NORMALIZE=1) also writes a PDF/X-4 copy in CMYK
+ with Ghostscript, -print-x4.pdf, and checks that one; the ISO Coated v2
+ profile is fetched from the ECI into dist/icc/ unless PDF_ICC_PROFILE
+ names one. `task pdf:print -- --gray` (or PDF_GRAY=1) writes a
+ greyscale preview, -print-gray-x4.pdf, to check on screen how the book
+ prints in black and white; it is PDF/X-4 by the grey profile Ghostscript
+ ships, and the two flags combine.
+ deps:
+ - task: build
+ env:
+ BUILD_VERSION: '{{.CURRENT_VERSION}}'
+ cmds:
+ - poetry run dec-tool build-pdf --edition print {{.CLI_ARGS}}
+
+ pdf:authors:
+ desc: Refresh the author list of the print edition's imprint
+ summary: |
+ Writes tools/pdf/authors.yml from the commits to what the print edition
+ prints - its pages that are not generated, and the images they
+ reference: most commits first, with the names the GitHub profiles show,
+ without anonymous commits, 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:
+ - poetry run dec-tool pdf-authors
+
update:icons:
desc: update the used eccenca icons from carbon
cmds:
diff --git a/docs/automate/processing-data-with-variable-input-workflows/index.md b/docs/automate/processing-data-with-variable-input-workflows/index.md
index 1470475dc..169067499 100644
--- a/docs/automate/processing-data-with-variable-input-workflows/index.md
+++ b/docs/automate/processing-data-with-variable-input-workflows/index.md
@@ -42,7 +42,7 @@ Click the :eccenca-module-marketplace: **Packages** icon in the main menu under
Search for the required ontologies / vocabularies and click the **Install** button.
Wait for a package installation to complete (the **Install** button will change to **Uninstall**) before installing the next package.
-{ class="bordered" }
+{ class="bordered" width="90%" }
## 2 Create a new project
diff --git a/docs/build/extracting-data-from-a-web-api/index.md b/docs/build/extracting-data-from-a-web-api/index.md
index 975525b72..54e7005af 100644
--- a/docs/build/extracting-data-from-a-web-api/index.md
+++ b/docs/build/extracting-data-from-a-web-api/index.md
@@ -95,7 +95,7 @@ To create a JSON-to-RDF-mapping within Corporate Memory, we have to first regist
1. Click the **Create** button (top right) in the data integration workspace and select the type **JSON**.
- 
+ { width="71%" }
2. Upload the JSON file [repos.json](repos.json) (API response) as a Dataset into Corporate Memory.
diff --git a/docs/build/lift-data-from-json-and-xml-sources/index.md b/docs/build/lift-data-from-json-and-xml-sources/index.md
index c3e97ec47..3098a6f75 100644
--- a/docs/build/lift-data-from-json-and-xml-sources/index.md
+++ b/docs/build/lift-data-from-json-and-xml-sources/index.md
@@ -309,7 +309,7 @@ The transformation defines how an input dataset (e.g.: JSON or XML) will be tran
=== "JSON"
- { class="bordered" }
+ { class="bordered" width="64%" }
=== "XML"
diff --git a/docs/build/loading-jdbc-datasets-incrementally/index.md b/docs/build/loading-jdbc-datasets-incrementally/index.md
index 3461908b3..bd38907f6 100644
--- a/docs/build/loading-jdbc-datasets-incrementally/index.md
+++ b/docs/build/loading-jdbc-datasets-incrementally/index.md
@@ -37,13 +37,13 @@ To extract data from a relational database, you need to first register a **JDBC
1. In Corporate Memory, click Projects under **BUILD** in the navigation on the left side of the page.
- { class="bordered" }
+ { class="bordered" width="29%" }
2. Click **Create** at the top of the page.
3. In the Create new item window, select Project and click Add. The Create new item of type Project window appears.
4. In the Create new item window, select Dataset under **Item Type**, search for JDBC endpoint, and click **Add**.
- { class="bordered" }
+ { class="bordered" width="71%" }
5. Provide the required configuration details for the JDBC endpoint:
- **Label**: Provide a table name.
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/docs/build/rule-blocks/index.md b/docs/build/rule-blocks/index.md
index 6be5611ba..a000e09dd 100644
--- a/docs/build/rule-blocks/index.md
+++ b/docs/build/rule-blocks/index.md
@@ -89,7 +89,7 @@ Dragging **New input port** onto the canvas creates a port as well.
Existing ports are listed below that entry.
Click the **:material-pencil-outline: edit** icon of a port to open the **Edit input port** dialog, or the **:material-delete-outline: delete** icon to remove it:
-
+{ width="62%" }
**Label**
: The name of the port.
@@ -145,7 +145,7 @@ It has one input handle per input port, in the port order defined in the rule bl
Its context menu offers the actions specific to the reference:
-
+{ width="32%" }
**Clone**
: Adds a second reference to the same rule block.
@@ -187,7 +187,7 @@ That also means a change can break those rules, so the editor restricts what may
Click the **:material-information-outline: Usage status** button next to **Save** to see the current state:
-
+{ width="75%" }
While a rule block is in use:
diff --git a/docs/build/rule-operators/index.md b/docs/build/rule-operators/index.md
index e7a94e531..f39516b03 100644
--- a/docs/build/rule-operators/index.md
+++ b/docs/build/rule-operators/index.md
@@ -54,7 +54,7 @@ The following parameters can be set for each comparison:
!!! info inline end ""
- 
+ { width="38%" }
The threshold is used to convert the computed distance to a confidence between -1.0 and 1.0. Links will be generated for confidences above 0 while higher confidence values imply a higher similarity between the compared entities.
diff --git a/docs/build/snowflake-tutorial/index.md b/docs/build/snowflake-tutorial/index.md
index 3f898c32c..67b1084b3 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')
@@ -1210,7 +1210,7 @@ Here you can populate some test data with the following SQL query:
- Select the **target property** according to transformation requirements, for example name, id, etc., then select the **value path** according to the target property as the product name, product id etc.
This step will help in mapping the data from the source to the target property.
- { class="bordered" }
+ { class="bordered" width="91%" }
- Type the label name **product name**, then click on **Save**.
@@ -1232,7 +1232,7 @@ Here you can populate some test data with the following SQL query:
Suggestion appears as below can select as per the requirement.
- { class="bordered" }
+ { class="bordered" width="91%" }
!!! note
@@ -1240,7 +1240,7 @@ Here you can populate some test data with the following SQL query:
- **Tick** the box to select the suggestions to be added, then click on **Add**.
-{ class="bordered" }
+{ class="bordered" width="93%" }
## 4. Create a knowledge graph
diff --git a/docs/build/tutorial-how-to-link-ids-to-osint/define-the-interfaces/index.md b/docs/build/tutorial-how-to-link-ids-to-osint/define-the-interfaces/index.md
index 23fe2db7c..c936fd293 100644
--- a/docs/build/tutorial-how-to-link-ids-to-osint/define-the-interfaces/index.md
+++ b/docs/build/tutorial-how-to-link-ids-to-osint/define-the-interfaces/index.md
@@ -10,21 +10,21 @@ In this tutorial, we will show how to (1) define the information available in an
In the previous tutorial, we have defined the use cases in contact with the humans, ie. analysts (see figure 1)
-
+{ width="50%" }
Figure 1. We need to imagine an interface where analysts can list the IoCs during the incident and read all their documentations.
The classic Splunk interface is a set of panels, like "static table" panel. This table panel can show a table of cells and also one cell with a text via in input a Splunk Search Processing Language (SPL). With the plugin [Linked Data App](../link-IDS-event-to-KG/eccenca_commands.tar.gz) ([tutorial page](../link-IDS-event-to-KG/index.md)), we can insert a SPARQL query and select the part of your knowledge graph to print (figure 2).
-
+{ width="75%" }
Figure 2. An analyst can insert a SPARQL query with Splunk token in input of one "static table" panel of his dashboard with the plugin "Linked Data App"
The first dashboard to do for our use cases is the list of IoCs with classic SPL queries of analysts via a static table and allow the analyst to select one IoC via a click in the table. The dashboard with this selected row can save the ID of IoC in a global variable for the other panels in the same dashboard ([a Splunk token](https://docs.splunk.com/Documentation/Splunk/9.0.5/Viz/tokens)). When this variable (Splunk token) is changed by the user, Splunk is able to recalculate automatically the queries with this variable in the other static tables. So with this mechanism, we can print the details in the knowledge graph (with SPARQL queries) and the IoC statistics in the Splunk indexes (with SPL queries) around of one selected IoC. With these knowledge about Splunk dashboard, we proposed to analysts a first naive interface in the figure 3.
-
+{ width="57%" }
Figure 3. Imagine the expected Splunk dashboard with its interaction
diff --git a/docs/build/tutorial-how-to-link-ids-to-osint/lift-data-from-STIX-2.1-data-of-mitre-attack/index.md b/docs/build/tutorial-how-to-link-ids-to-osint/lift-data-from-STIX-2.1-data-of-mitre-attack/index.md
index 8d176897b..22e50d0df 100644
--- a/docs/build/tutorial-how-to-link-ids-to-osint/lift-data-from-STIX-2.1-data-of-mitre-attack/index.md
+++ b/docs/build/tutorial-how-to-link-ids-to-osint/lift-data-from-STIX-2.1-data-of-mitre-attack/index.md
@@ -77,7 +77,7 @@ Each domain dataset is saved in GitHub:
!!! Success
Now, you can see these JSON datasets in Corporate Memory:
- 
+ { width="80%" }
### Create named graphs
@@ -118,7 +118,7 @@ Create one RDF dataset for each Mitre dataset:
!!! Success
Now, you can see these RDF datasets in Corporate Memory:
- 
+ { width="80%" }
!!! Tip
@@ -404,7 +404,7 @@ Create one RDF dataset for each Mitre dataset:
You can test the result when you search the Mitre ID via the explorer of knowledge graph "MA Entreprise":
- 
+ { width="55%" }
### Create a workflow
@@ -754,7 +754,7 @@ After this tutorial, you want probably to navigate in your new knowledge graph b
5. Create a new workflow "MITRE ATT&CK® workflow" where you will insert the other workflows, like that:
- 
+ { width="75%" }
!!! Success
diff --git a/docs/build/tutorial-how-to-link-ids-to-osint/lift-data-from-YAML-data-of-hayabusa-sigma/index.md b/docs/build/tutorial-how-to-link-ids-to-osint/lift-data-from-YAML-data-of-hayabusa-sigma/index.md
index ec1c1fa19..08db384d9 100644
--- a/docs/build/tutorial-how-to-link-ids-to-osint/lift-data-from-YAML-data-of-hayabusa-sigma/index.md
+++ b/docs/build/tutorial-how-to-link-ids-to-osint/lift-data-from-YAML-data-of-hayabusa-sigma/index.md
@@ -134,7 +134,7 @@ This new transformer are building the following RDF model for your use case:
- replace `\./hayabusa-rules/` by `https://github.com/Yamato-Security/hayabusa-rules/blob/main/`
- replace `\./sigma/` by `https://github.com/SigmaHQ/sigma/blob/master/`
- 
+ { width="95%" }
So the rulepath `./sigma/rules/windows/process_creation/proc_creation_win_bcdedit_boot_conf_tamper.yml` becomes the link `https://github.com/SigmaHQ/sigma/blob/master/rules/windows/process_creation/proc_creation_win_bcdedit_boot_conf_tamper.yml` and `./hayabusa-rules/hayabusa/sysmon/Sysmon_15_Info_ADS-Created.yml`becomes `https://github.com/Yamato-Security/hayabusa-rules/blob/main/hayabusa/sysmon/Sysmon_11_Med_FileCreated_RuleAlert.yml`
@@ -155,7 +155,7 @@ This new transformer are building the following RDF model for your use case:
And don't forget to allow the replacement of JSON dataset because it allows to replace this specific JSON by all other rules during the execution of this worflow.
- 
+ { width="65%" }

diff --git a/docs/build/tutorial-how-to-link-ids-to-osint/link-IDS-event-to-KG-via-cmem/index.md b/docs/build/tutorial-how-to-link-ids-to-osint/link-IDS-event-to-KG-via-cmem/index.md
index cc9e47626..2e49803f6 100644
--- a/docs/build/tutorial-how-to-link-ids-to-osint/link-IDS-event-to-KG-via-cmem/index.md
+++ b/docs/build/tutorial-how-to-link-ids-to-osint/link-IDS-event-to-KG-via-cmem/index.md
@@ -56,17 +56,17 @@ For each investigation, an analyst selects and navigates in the data with two da
-
+{ width="68%" }
Figure 2: High-level investigation dashboard with the list of computers implicated in the incident
-
+{ width="69%" }
Figure 3: High-level investigation dashboard with the panel to select a specific period during an incident according to IoCs details
-
+{ width="73%" }
Figure 4: low-level investigation dashboard contains the command lines of Windows processus rised Suricata alerts during the period selected by the analyst in a high-level investigation dashboard
@@ -75,7 +75,7 @@ The idea is the analyst can do an investigation high-level without using a lot o
To follow the calculation of investigations and free memory when one investigation is closed, we developed another dashboard "investigation_list.xml" (see figure 5). This dashboard prints the status of investigations actually in the knowledge graph. Here, each investigation is saved in tempory graphs and the analyst can create and delete them directly in Splunk.
-
+{ width="82%" }
Figure 5: The dashboard "investigation list" shows all the tempory graph actually in the knowledge graph. The analyst can open an investigation, see the SPL query generated when he has created an investigation and delete it when he want.
diff --git a/docs/build/tutorial-how-to-link-ids-to-osint/link-IDS-event-to-KG/index.md b/docs/build/tutorial-how-to-link-ids-to-osint/link-IDS-event-to-KG/index.md
index 776a74185..3e4fa775d 100644
--- a/docs/build/tutorial-how-to-link-ids-to-osint/link-IDS-event-to-KG/index.md
+++ b/docs/build/tutorial-how-to-link-ids-to-osint/link-IDS-event-to-KG/index.md
@@ -27,7 +27,7 @@ The "Linked Data App" extends Splunk Search Processing Language (SPL) to support
2. Open the App window in Splunk via the icon "tools" (see figure 1)
-
+{ width="30%" }
Figure 1: In the top of the list of installed Splunk apps, you need to click on the icon "tools" to open the window to manage your apps
diff --git a/docs/build/variables/index.md b/docs/build/variables/index.md
index a55d06ce0..213c19e85 100644
--- a/docs/build/variables/index.md
+++ b/docs/build/variables/index.md
@@ -114,7 +114,7 @@ In order to add project variables, login to eccenca Corporate Memory, select the
On the right side of the page you can see the variable section Click on :eccenca-item-add-artefact: to add the variables.
-{ class="bordered" }
+{ class="bordered" width="80%" }
The variable definition dialog opens.
Type the name, value and description of the variable.
@@ -138,7 +138,7 @@ Type name as `email_ids`, in values we have updated all the email id’s of the
The email_ids variable is added as shown below.
- { class="bordered" }
+ { class="bordered" width="84%" }
## Execution Variables
@@ -225,7 +225,7 @@ Select the `project.email_ids` and include the double curly brackets at last.
Same as above, add the variable click on :eccenca-item-add-artefact: and type name as `password`, value as `xmhnjnnjkmnlbbhbvfhnbjkm` and in the description `Gmail account password` as shown below then click on **Add**.
-{ class="bordered" }
+{ class="bordered" width="76%" }
!!! success "Step Result"
diff --git a/docs/build/workflows/index.md b/docs/build/workflows/index.md
index c4c8ca338..313f42bf2 100644
--- a/docs/build/workflows/index.md
+++ b/docs/build/workflows/index.md
@@ -55,7 +55,7 @@ The **Clear Dataset** operator empties the dataset connected to its output befor
Place the Clear Dataset operator in the workflow and connect its output to the dataset that should be cleared. The operator takes no data inputs; connect it using a **dependency connection** from the upstream node that must complete first, or leave it unconnected if the dataset should be cleared before any subsequent nodes execute.
-
+{ width="57%" }
Some datasets historically provided their own clear attributes (e.g., `Clear graph before workflow execution` on the Knowledge Graph dataset). These per-dataset attributes are **deprecated** and should no longer be used. Use the Clear Dataset operator instead, which works uniformly across all dataset types.
@@ -67,7 +67,7 @@ The operators of a workflow can be reconfigured completely in the context of a w
During its execution, new parameters are loaded from any possible source and translated by a transformation task to allow an injection into the dataset configuration that overwrites originally set parameters.
To reconfigure a workflow operator, the transformation task has to be connected to the red dot at the top of this operator as shown in the following image:
-
+{ width="86%" }
Although this feature has been developed to support the ingestion of database deltas, the possible applications are various since any parameter can be overwritten to make workflow operators even more dynamic and reusable in various contexts.
The incremental ingestion of database content that was implemented as a first use-case can be found the application section of this page.
diff --git a/docs/consume/consuming-graphs-in-power-bi/index.md b/docs/consume/consuming-graphs-in-power-bi/index.md
index 68f2d7a8b..25050c158 100644
--- a/docs/consume/consuming-graphs-in-power-bi/index.md
+++ b/docs/consume/consuming-graphs-in-power-bi/index.md
@@ -37,7 +37,7 @@ The latest (unsigned) version of our Power-BI-Connector is available from its so
- The registry path is `HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Power BI Desktop` . Make sure the path exists, or create it.
- Add a new value under the path specified above. The type should be “Multi-String Value” ( `REG_MULTI_SZ` ), and it should be called `TrustedCertificateThumbprints`
- Add the thumbprints of the certificates you want to trust. You can add multiple certificates by using “\0” as a delimiter, or in the registry editor, right click → modify and put each thumbprint on a new line.
- 
+ { width="95%" }
- (Re-)Start Power BI Desktop
If you wish to automate this setup you can use the reg windows command line tool to make this entry like:
diff --git a/docs/consume/consuming-graphs-with-sql-databases/index.md b/docs/consume/consuming-graphs-with-sql-databases/index.md
index 096c825a8..8d3e5bb50 100644
--- a/docs/consume/consuming-graphs-with-sql-databases/index.md
+++ b/docs/consume/consuming-graphs-with-sql-databases/index.md
@@ -29,7 +29,7 @@ In the following, we have a more detailed look at each of the three operators.
Create a dataset of the type Knowledge Graph (embedded) and set the graph parameter to the URI of the graph that contains the resources to be exported:
-
+{ width="70%" }
### Create Transformation
diff --git a/docs/consume/populate-data-to-neo4j/index.md b/docs/consume/populate-data-to-neo4j/index.md
index d29c7ae79..656163eaf 100644
--- a/docs/consume/populate-data-to-neo4j/index.md
+++ b/docs/consume/populate-data-to-neo4j/index.md
@@ -36,20 +36,20 @@ This tutorial uses the _Multi CSV Zip_ format to efficiently handle the followin
To upload multiple files together as an input:
1. In Corporate Memory, click Projects under **BUILD** in the navigation on the left side of the page.
- {width="50%"}
+ {width="29%"}
2. Click Create at the top of the page.
3. In Create new item window, select Project and click Add. The Create new item of type Project window appears.
4. Fill in the required details such as Title and Description. Alternatively, import the existing project by clicking Import Project File and selecting the file from your system.
5. Click Create. Your project (Northwind) is created.
- 
+ { width="79%" }
6. In your project, click Create Item.
7. In the Create new item window, select Multi CSV ZIP and click Add.
- 
+ { width="64%" }
8. Specify a Label of the dataset in the Create new item of type Multi CSV ZIP window.
9. Select the Upload new file option as you have the files. The Multi CSV ZIP file containing the above-listed files is available here. If it is an existing project, you can select the files from the project. For the remaining parameters, the default settings are used.
10. Click Create. You can see the message northwind.zip was successfully uploaded in Green.
11. You can see the Multi CSV ZIP file is uploaded with the datasets, and the item has been created.
- 
+ { width="71%" }
12. Click the Play button and review the dataset in the Data preview section. You can see the contents of the loaded zip file consisting of the CSV files introduced above.
## 2 Create a Neo4j dataset
@@ -59,7 +59,7 @@ Perform the following steps to create a Neo4j dataset:
1. In your existing project, click Create to create a new item.
2. In the item category Dataset select Neo4j.
- 
+ { width="68%" }
3. Click Add.
4. Enter the following details:
- Label: _Name of the item_.
@@ -81,7 +81,7 @@ The provided vocabulary is inspired by this structure from the original Neo4j tu
The vocabulary for the Northwind project can be visualized like this:
-
+{ width="96%" }
## 4 Create Transformations
@@ -263,7 +263,7 @@ Click the Play icon to validate the results. The nodes and edges created are sto
## 6 Results in Neo4j
-
+{ width="89%" }
## 7 Results in Knowledge Graph
@@ -272,4 +272,4 @@ Optionally, you can use the same transformation and workflow to render the resul
1. Add a Knowledge Graph dataset and use this as an additional target in your workflow:

2. The results can then be reviewed in the Knowledge Graph module, e.g., explored visually:
- 
+ { width="72%" }
diff --git a/docs/deploy-and-configure/configuration/keycloak/change-passwords-and-keys/index.md b/docs/deploy-and-configure/configuration/keycloak/change-passwords-and-keys/index.md
index 8eed7ed7a..498504306 100644
--- a/docs/deploy-and-configure/configuration/keycloak/change-passwords-and-keys/index.md
+++ b/docs/deploy-and-configure/configuration/keycloak/change-passwords-and-keys/index.md
@@ -50,7 +50,7 @@ To change the default passwords, select the Cmem Realm and open Users in the lef

-
+{ width="75%" }
Then, select "View all users" and choose an account you want to change the password for (we start with admin)
diff --git a/docs/develop/packages/installation/index.md b/docs/develop/packages/installation/index.md
index 49788a23a..0da014ce3 100644
--- a/docs/develop/packages/installation/index.md
+++ b/docs/develop/packages/installation/index.md
@@ -74,7 +74,7 @@ Depending on the content types inside it, an installed package appears in differ
!!! info inline ""
- 
+ { width="68%" }
**Graphs** such as data graphs but also **Vocabularies** or **Shapes Catalogs** are listed in [**Explore > Graphs**](../../../explore-and-author/graph-exploration/index.md#graphs).
diff --git a/docs/develop/python-plugins/development/index.md b/docs/develop/python-plugins/development/index.md
index 0c9c13ad7..a1b95218b 100644
--- a/docs/develop/python-plugins/development/index.md
+++ b/docs/develop/python-plugins/development/index.md
@@ -350,7 +350,7 @@ The method returns a list of `Autocompletion` objects, which represent the possi
The [cmem-plugin-base](https://github.com/eccenca/cmem-plugin-base/) package describes [context objects](https://github.com/eccenca/cmem-plugin-base/blob/main/cmem_plugin_base/dataintegration/context.py), which are passed to the plugin depending on the executed method.
-
+{ width="75%" }
### Basic Understanding
@@ -453,7 +453,7 @@ The following image shows these terms and their relationships. (1)
1. The concrete implementation details of entities can be found in the [entity module](https://github.com/eccenca/cmem-plugin-base/blob/main/cmem_plugin_base/dataintegration/entity.py) of the cmem-plugin-base package.
-
+{ width="90%" }
| Class | Description |
| -------------- | ------------------------------------------------------------------ |
diff --git a/docs/distribution/marketplace/index.md b/docs/distribution/marketplace/index.md
index af7fb3888..dd7b27a27 100644
--- a/docs/distribution/marketplace/index.md
+++ b/docs/distribution/marketplace/index.md
@@ -61,7 +61,7 @@ They can be combined.
!!! info inline ""
- 
+ { width="21%" }
#### Package type
@@ -73,7 +73,7 @@ Show only **Vocabulary** or only **Project** packages, see [Package Types](#pack
!!! info inline ""
- 
+ { width="32%" }
#### License
@@ -93,7 +93,7 @@ Show only packages whose name or description contains the entered keyword.
!!! info inline ""
- 
+ { width="14%" }
#### Installed
@@ -105,7 +105,7 @@ Switch on to show only the packages currently installed in your instance.
!!! info inline ""
- 
+ { width="38%" }
#### Marketplace (top right)
diff --git a/docs/explore-and-author/bke-module/visually-authoring-ontologies/index.md b/docs/explore-and-author/bke-module/visually-authoring-ontologies/index.md
index fcc002f0e..d203c85d2 100644
--- a/docs/explore-and-author/bke-module/visually-authoring-ontologies/index.md
+++ b/docs/explore-and-author/bke-module/visually-authoring-ontologies/index.md
@@ -46,7 +46,7 @@ To create a new ontology graph:
1. In Corporate Memory, click **Knowledge graphs** under **EXPLORE** in the navigation on the left side of the page.
- { class="bordered" width="50%" }
+ { class="bordered" width="29%" }
2. In the **Graphs** drop-down menu, click the **(+)** button and select **New Ontology (owl:Ontology)**.
@@ -62,7 +62,7 @@ To create a new ontology graph:
1. In Corporate Memory, click **Business Knowledge Editor** under **EXPLORE** in the navigation on the left side of the page.
- { class="bordered" width="50%" }
+ { class="bordered" width="29%" }
2. Select the target graph using the drop-down menu.
@@ -82,7 +82,7 @@ New elements can be created from the entries listed in **Classes** on the left s
1. Drag and drop **Class** from the bottom left list into the canvas.
- { class="bordered" width="50%" }
+ { class="bordered" width="28%" }
!!! info
@@ -130,7 +130,7 @@ New elements can be created from the entries listed in **Classes** on the left s
7. Drag and drop **New Class** into the canvas.
- { class="bordered" width="50%" }
+ { class="bordered" width="49%" }
8. Click the newly created class to open its form, then fill out the required fields and any optional fields you want to define.
@@ -181,7 +181,7 @@ Save the changes as a named visualization so you can edit your classes and prope
3. Enter a name for the visualization.
- 
+ { width="76%" }
4. Click **Save**.
diff --git a/docs/explore-and-author/graph-exploration/building-a-customized-user-interface/sparql-constraints/index.md b/docs/explore-and-author/graph-exploration/building-a-customized-user-interface/sparql-constraints/index.md
index f062de3aa..7be926f60 100644
--- a/docs/explore-and-author/graph-exploration/building-a-customized-user-interface/sparql-constraints/index.md
+++ b/docs/explore-and-author/graph-exploration/building-a-customized-user-interface/sparql-constraints/index.md
@@ -38,7 +38,7 @@ Click the :eccenca-module-marketplace: **Packages** icon in the main menu under
Search for the required ontologies / vocabularies, then click the **Install** button to install the "The RDF Schema Vocabulary" and "Friend of a Friend (FOAF)" vocabulary.
Wait until each installation is complete (the **Install** button will change to **Uninstall**) before installing the next package.
-{ class="bordered" }
+{ class="bordered" width="90%" }
### Create Shape Catalog
diff --git a/docs/explore-and-author/graph-exploration/building-a-customized-user-interface/workflow-trigger/index.md b/docs/explore-and-author/graph-exploration/building-a-customized-user-interface/workflow-trigger/index.md
index b1bb2432e..ac311bfb0 100644
--- a/docs/explore-and-author/graph-exploration/building-a-customized-user-interface/workflow-trigger/index.md
+++ b/docs/explore-and-author/graph-exploration/building-a-customized-user-interface/workflow-trigger/index.md
@@ -36,7 +36,7 @@ A `shui:WidgetIntegration` resource needs to be created from the previously defi
Once the widget is created, it can be attached to a Node Shape by using a special-purpose non-validating [Widget property](../node-shapes/index.md#widgets).
-
+{ width="62%" }
## Payload Structure
diff --git a/docs/explore-and-author/graph-exploration/statement-annotations/index.md b/docs/explore-and-author/graph-exploration/statement-annotations/index.md
index cfb39d898..94cbd6779 100644
--- a/docs/explore-and-author/graph-exploration/statement-annotations/index.md
+++ b/docs/explore-and-author/graph-exploration/statement-annotations/index.md
@@ -76,7 +76,7 @@ These Annotation Resources are based on specific Shapes which are enabled as Sta
Reification Resources as well as Annotation Resources are managed in a Statement Annotation Graph, which need to be configured on a Graph as well as imported to this Graph.
The following illustration depicts this schema with boxes and arrows:
-{ class="bordered" }
+{ class="bordered" width="91%" }
!!! note "Some notes on this:"
diff --git a/docs/explore-and-author/workspace-configuration/index.md b/docs/explore-and-author/workspace-configuration/index.md
index 639fa07ec..14e03ee4d 100644
--- a/docs/explore-and-author/workspace-configuration/index.md
+++ b/docs/explore-and-author/workspace-configuration/index.md
@@ -25,7 +25,7 @@ Click the drop-down list and click the Application view you want to open.
Click the :eccenca-module-workspace-configuration: **Application view configuration** icon in the main menu.
-{ class="bordered" }
+{ class="bordered" width="29%" }
Select the Application view from the dropdown menu to configure.
diff --git a/poetry.lock b/poetry.lock
index 78664caa9..fe425c224 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"
@@ -1557,6 +1682,28 @@ files = [
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
+[[package]]
+name = "pypdf"
+version = "6.19.0"
+description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pypdf-6.19.0-py3-none-any.whl", hash = "sha256:7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14"},
+ {file = "pypdf-6.19.0.tar.gz", hash = "sha256:bbc43aca292369ccc6cbc8a921991ecf2538a3587ab5a116eff06c321d647155"},
+]
+
+[package.extras]
+crypto = ["cryptography (>3.0)"]
+cryptodome = ["PyCryptodome"]
+dev = ["flit", "pip-tools", "pre-commit", "pytest-cov", "pytest-socket", "pytest-timeout", "pytest-xdist", "wheel"]
+docs = ["myst_parser", "sphinx", "sphinx_rtd_theme"]
+fonts = ["fonttools"]
+full = ["Pillow (>=8.0.0)", "arabic-reshaper", "cryptography (>3.0)", "fonttools", "python-bidi"]
+image = ["Pillow (>=8.0.0)"]
+rtl-text = ["arabic-reshaper", "python-bidi"]
+
[[package]]
name = "pytest"
version = "9.1.1"
@@ -2127,4 +2274,4 @@ tomli = ">=2.4.0"
[metadata]
lock-version = "2.1"
python-versions = "^3.11"
-content-hash = "972f99668ce7d19c84d91a6adf50dff73bf6d25a027eed6cdd24ac974d0eba34"
+content-hash = "958cf277fc91a7ebe12226353501b35afbcb3c5f2fa267d0d3f4d7725c1c5a89"
diff --git a/pyproject.toml b/pyproject.toml
index 498e5ef67..18e0713f5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,6 +40,19 @@ 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.63"
+pillow = "^11.3.0"
+# tools/pdf_normalize.py and tools/pdf_preflight.py read the PDFs the print
+# edition writes: the PDF/X-4 marker, the output intent, page sizes and annotations.
+pypdf = "^6.18"
+# Every dec-tool subcommand is a click command (tools/__init__.py). click, markdown
+# and requests used to arrive as dependencies of other packages; a tool that imports
+# them directly declares them, or an upstream bump takes them away.
+click = "^8.5"
+# tools/build_pdf.py renders the Markdown fields of data/plugins.json for the
+# operator reference of the print edition.
+markdown = "^3.10"
+# tools/update_icons.py fetches the eccenca icon set from the gui-elements repository.
+requests = "^2.32"
[tool.poetry.group.dev.dependencies]
linkcheckmd = "^1.4.0"
diff --git a/tasks/backlog.md b/tasks/backlog.md
index 5dea765fe..2152a6576 100644
--- a/tasks/backlog.md
+++ b/tasks/backlog.md
@@ -1,270 +1,716 @@
-# 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-16: P0-P16 and P18-P26 done and verified;
+P17 (the cover) 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, 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. D12-D14, decided
+2026-09-15: excluding content from the print edition (spec §10), implemented by P18. D15-D18, decided
+2026-09-15: the findings of the pull request review (spec §11), backlog P19-P24.
-| # | 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:
+- 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
-```
- -> no filter
- -> include=[BeginnersTutorial]
-```
+**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.
+
+**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).
+
+---
+
+## P4 - Recto starts and blank pages - **done**
+
+- 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
+
+**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.
+
+**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.
+
+---
-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.
+## P5 - Title page - **done**
-**Verify:** finds exactly 4 markers across 2 files in the current build.
+Publisher **eccenca GmbH** on the title block; site link and copyright move to the imprint.
+
+**Verify:** render page 1.
+**Est:** trivial. **Depends on:** P4.
+
+**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.
+
+---
+
+## 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
+
+**Verify:** unit tests for ordering, tie-break and exclusion; the generated file matches the spec §3
+table (22 IDs).
**Est:** small. **Depends on:** nothing.
+**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.
+
+**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**
+
+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.
+
+**Verify:** render page 2; the author order matches `authors.yml`.
+**Est:** small. **Depends on:** P5, P6.
+
+**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.
+
+**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).
+
---
-## B3 — HTML renderer
+## 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.
-Emit the markup in spec §3 for one listing.
+---
+
+## P9 - Links on paper - **done**
+
+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.
-- 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:** 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:** 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:** 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.
---
-## B4 — `tools/render_tag_listings.py`
+## P10 - Typography for print - **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.
+- 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)
-- 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:** page count after each change; render pages with justified text.
+**Est:** small. **Depends on:** P1.
-**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:** 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.
---
-## B5 — Wire into the build
+## P11 - Black-and-white palette - **done**
-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.
+- 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
-**Verify:** `task clean build` renders listings; `task check` still passes.
-**Est:** trivial. **Depends on:** B4.
+**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.
+
+**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 did not exist then (it does now: `--gray`), 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.
---
-## B6 — Promote the guard
+## P12 - Print images - **done**
+
+In `tools/build_pdf.py`, for the print edition: every image Typst embeds is a normalized copy in
+`dist/pdf/images/`.
-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.
+- 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
-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.
+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.
+
+**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.
+
+**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.
+
+---
-**Verify:** deliberately skip the render step; `task check` must fail.
-**Est:** medium — mostly deciding the probe. **Depends on:** B4, B5.
+## P13 - Normalization pass (optional) - **done**
+
+Done 2026-09-15: `dec-tool pdf-normalize` (`tools/pdf_normalize.py`), run by `task pdf:print -- --normalize`
+or PDF_NORMALIZE=1, writes `…-print-x4.pdf`. Refinements:
+
+- **Profile not vendored:** its copyright reads "All Rights Reserved" (spec §7). It is fetched from the ECI
+ into `dist/icc/` with a SHA-256 check; `--icc-profile` / PDF_ICC_PROFILE name a copy.
+- **No prefix file in the repository:** the pdfmark prefix is generated with the title and the profile's path.
+- **Transparency first:** Ghostscript 10.08 segfaults on Typst's colour emoji and leaves out transparent
+ SVG content (spec §7). The print edition therefore renders the 5 transparent SVGs and the 10 emoji sequences
+ to PNG with Typst; the style swaps emoji by show rule, in code too. The screen PDF is verified unchanged.
+- **Failures are loud:** the run fails on a non-zero exit, on `error executing PDF token` and on a missing
+ PDF/X marker, and deletes the output.
+- **Checked twice:** the build runs the preflight (P14) on the book block before Ghostscript, and on the
+ PDF/X-4 copy with `--pdfx` after.
+- **Greyscale preview:** `task pdf:print -- --gray` (PDF_GRAY=1; `dec-tool pdf-normalize --gray` for an
+ existing PDF) writes `…-print-gray-x4.pdf`. It is the same pass in DeviceGray, and it serves as a screen
+ check of the black-and-white print for P11. It combines with `--normalize`. Verified on the 868-page
+ book block: 181 s, 59 MB, all 556 images grey, A4 unchanged. Since 2026-09-16 it is PDF/X-4 as well,
+ by the generic `default_gray.icc` of Ghostscript (condition `sGray`, one component, no registry).
+ Verified on the 872-page book: 176 s, 57 MB, the marker and a one-component intent, all 556 images grey,
+ and `dec-tool pdf-preflight --pdfx --intent sGray` passes; four unit tests.
+
+Verified on the full book block: 868 pages, Ghostscript 218 s, 175 MB; the preflight passes with
+`--pdfx`: PDF/X-4 marker, FOGRA39 intent, 556 images all CMYK and none above 300 ppi, fonts embedded, no
+Type 3 font, no transparency, no annotations. Rendered pages 26 (card icons), 472 (Excalidraw diagram)
+and 662 (emoji) match the RGB file. 9 unit tests, 5 more for the rendered emoji and SVGs.
+
+`dec-tool pdf-normalize ` (run by `task pdf:print` with `--normalize` / `PDF_NORMALIZE=1`), spec §7:
+
+- 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)
+
+**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.
---
-## B7 — Tests
+## P14 - Preflight report - **done**
-`tests/test_render_tag_listings.py`, following `tests/test_update_di_reference.py`.
+Done 2026-09-15: `dec-tool pdf-preflight [--pdfx] [--max-pages]` (`tools/pdf_preflight.py`). Page
+sizes, annotations and fill colours come from pypdf, fonts, images and word boxes from poppler's
+`pdffonts`, `pdfimages -list` and `pdftotext -bbox`. Light areas are a **warning** that does not fail the
+run - artwork and emoji carry light fills too; every other check is an error. With `--pdfx` it also
+requires the PDF/X-4 marker, a FOGRA39 output intent and CMYK or grey images at most 300 ppi.
+`build-pdf --edition print` runs it on its final file, the normalized one when P13 ran, and fails on an
+error after writing the PDF. Verified: 9 unit tests; the print book block (868 pages) passes every check
+in 7 s; the screen PDF fails as it should - 778 pages with annotations, 840 page numbers on the inner
+edge, soft masks, light fills. Refinement: a fill of exactly 20 % black (`0.8 g`, 0.19999… in floating
+point) counts as passing - the first run reported it on 102 pages.
-- 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
+`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:** `task test:unit` stays green.
-**Est:** medium. **Depends on:** B1–B4.
+**Verify:** unit tests on small fixture PDFs; deliberately break one rule, the report must fail.
+**Est:** medium. **Depends on:** P3, P4, P8.
---
-## B8 — Documentation
+## P15 - Low-resolution originals - **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.
+49 images were below 150 ppi at printed size; resampling (P12) hides that from the preflight but adds no
+detail. After P18, 43 images in the printed pages that are not generated print below 150 ppi, 37 of them
+without a `width` - P24 sizes those first (spec §11). Work through what stays below 150 ppi: replace it
+with a fresh screenshot, or accept it in spec §8.
-**Est:** trivial. **Depends on:** B5.
+**Verify:** every entry replaced or accepted.
+**Est:** medium, mostly content. **Depends on:** P12, P24.
+
+Done 2026-09-16 by the revised P24: no screenshot had to be replaced. Narrowing the 42 entries to the
+width their pixels support empties the report, so `accepted-low-resolution` in `tools/pdf/print.yml`
+stays an empty list, and the report keeps watch over new screenshots.
+
+Tooling done 2026-09-15: the print build collects every original below 150 ppi at its printed size
+(`resolve_images(..., low_resolution)`), leaves out those listed under `accepted-low-resolution` in
+`tools/pdf/print.yml`, writes the rest lowest first to `dist/pdf/print/low-resolution.tsv` - density, the
+width the page declares (the `width="NN%"` of the Markdown source, empty where there is none) and the
+image - and prints their count. Accepting moved from spec §8 to `print.yml`, next to the other print
+settings. The 42 entries it listed - 85 to 149 ppi, most in `consume/populate-data-to-neo4j` (8),
+`explore-and-author/bke-module` (5) and `distribution/marketplace` (4) - are resolved by the revised P24:
+the report is empty.
---
-## Sequencing
+## P16 - CI - **done**
+
+Done 2026-09-16: `.github/workflows/pdf.yml` builds every edition after a push to `main` or to
+`feature/print-on-demand`, and on demand from the Actions tab - the screen PDF from `task pdf`, and the
+book block, its PDF/X-4 copy in CMYK and the greyscale preview from `task pdf:print -- --normalize --gray`.
+
+- **The runner gets poppler; Ghostscript comes from a container.** The preflight shells out to `pdffonts`,
+ `pdfimages` and `pdftotext`, so poppler is installed with apt. Ghostscript is not: Ubuntu ships 10.02,
+ where `-dPDFX=4` is a boolean and the 4 raises `/typecheck in --pdfmark--` (spec §7). The workflow builds
+ an `alpine:edge` image with Ghostscript 10.07 and writes a shim to `/usr/local/bin/gs-docker` that runs
+ it with the workspace and `/tmp` mounted at the paths they already have, so the PDFs, the pdfmark prefix
+ and the profiles resolve inside the container as they do outside. `GHOSTSCRIPT` points the build at the
+ shim, and `default_gray.icc` is copied out of the same image into `dist/icc`, which the greyscale pass
+ finds through `PDF_GRAY_PROFILE`. Verified end to end against that image: the CMYK copy carries the
+ FOGRA39 intent with four components, the preview the `sGray` intent with one.
+- **Four artifacts**, one per edition (`pdf-screen`, `pdf-print`, `pdf-print-x4`, `pdf-print-gray-x4`),
+ kept 30 days and stored uncompressed, since a PDF is compressed already.
+- **The run summary** lists each edition with its page count, size and download link, so nothing has to be
+ dug out of the artifact section (user decision 2026-09-16: summary links, no release page). The first
+ run wrote nothing there, although the step passed, so the table is now built in one block, printed to
+ the log with `tee` as well, and followed by the byte count of the summary file - a silent non-write
+ cannot pass for success again. **Open:** the next run has to show the table in both places.
+- **The output intent profile** is cached in `dist/icc` between runs; its licence keeps it out of the
+ repository, and CI would otherwise fetch it from the ECI every time.
+- **The grey profile** is located with `find` and passed as `PDF_GRAY_PROFILE`, because distributions put
+ Ghostscript's `default_gray.icc` in different places; `ensure_gray_profile` honours that variable.
+- Concurrency is per ref, so a push to `main` no longer cancels a branch build.
+
+**Verify:** a push produces the four artifacts and the summary table; a failing preflight fails the job.
+**Est:** small. **Depends on:** P1, P13, P14.
-```
-B0 ─┬─> B1 ─┬─> B3 ──> B4 ──> B5 ──> B6 ──> B8
- └─> B2 ─┘ └──> B7
-```
+---
-B1 and B2 are independent and can start as soon as Q1/Q4 are settled.
+## P17 - Cover (separate deliverable)
-## Risks
+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.
-- **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.
+**Depends on:** P8, P14.
---
-# Part 2: linked tag chips (spec §9)
+## 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` (P26 widened it to all of `develop/`),
+ `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, widened to all of `develop/` by P26) 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.
-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.**
+---
-Smaller than Part 1 and a different mechanism - a **template override**, not another
-post-build pass, so it also works under `task serve`.
+## P19 - Part label in the footer - **done**
-## Q6 — Decide before coding — **done**, all as recommended
+Spec §11: the running footer of a left-hand page prints the part as the part band does, `Part A: Build`.
+The contents, the bookmarks and the right-hand footer keep theirs. `print-footer` in `tools/pdf/style.typ`.
-Spec §9 open questions:
+**Verify:** render a left-hand and a right-hand page of two parts; the screen PDF is unchanged.
+**Est:** small. **Depends on:** P3.
-| # | 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-footer` prints a part on a left-hand page as `Part A: Build`; right-hand footers,
+contents and bookmarks keep `A Build`. 313 left-hand pages carried the label in the build before P23;
+the screen PDF is unchanged.
-**Est:** one review pass.
+---
+
+## P20 - Web addresses as endnotes - **done**
+
+Spec §11, D15. In the print edition, a link out of the book prints its text and a superscript number
+instead of a footnote. The numbers run within a part and start again at 1 in the next; an address cited
+twice in a part keeps its first number. A list "Web addresses" on a new page, under an unnumbered heading,
+closes each part that cites any: number, address and the pages citing it, laid out without link
+annotations. Links within the book keep their page reference.
+
+**Verify:** no footnote left in the book block; each part's list holds every number of that part with its
+address and correct pages; numbering restarts per part; no link annotations from notes; Typst reports no
+layout that failed to converge; compile time and page count before and after.
+**Est:** medium. **Depends on:** P9.
-Decision: go with all above recommendations!
+**Done 2026-09-15:**
+
+- `web-address()` in `style.typ` places the address as metadata and prints a superscript number, counted
+ within the part with `context` and `query`. `part-addresses()` sets the list on a new page under an
+ unnumbered heading that is bookmarked but not in the contents: number, address and the pages citing it.
+- In the print edition, `merge_pages` puts a `part-end` marker at the end of every part, and
+ `filter.lua` turns it into `#part-addresses()`; a unit test covers the markers.
+- Measured with P23 in place: 8 lists with 390 entries, 159 of them in part A. Typst compiles in 4 s
+ without a convergence warning; the screen PDF is unchanged.
---
-## C1 — `overrides/partials/tags.html` — **done**
+## P21 - Author order from the printed content - **done**
-Copy Zensical's `partials/tags.html` verbatim and replace only the `tag.url` branch:
+Spec §11, D16. `dec-tool pdf-authors` counts only the commits to the files the print edition
+prints: the pages left after the section rules and the images in their directories, from
+`git log --no-merges` following renames, each commit once, generated pages left out. The GitHub
+commits API maps each commit to its account. Bots, agents, anonymous commits and `authors.exclude`
+stay excluded; the imprint says "most commits to the printed pages first".
-```jinja
-{% set anchor = "tags/" | url ~ "#tag:" ~ (tag.name | lower | replace(" ", "-")) %}
-{{- tag.name -}}
-```
+**Verify:** unit tests with a fake history for file selection, rename following, counting and account
+mapping; a real run shows the new order next to today's; the imprint follows it.
+**Est:** medium. **Depends on:** P6, P18.
-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.
+**Done 2026-09-15:**
-**Done:** output is byte-identical to production's, e.g.
-`Configuration`.
-The override differs from Zensical's stock template by exactly the one branch.
+- `tools/pdf_authors.py` counts the commits to the printed files. `printed_pages` applies the section
+ rules, and `printed_files` returns the pages that are not generated plus the images they reference.
+ `file_commits` runs `git log --no-merges --follow` per file, and `commit_accounts` with
+ `count_commits` maps each commit once to its account, falling back to the author e-mail.
+ `fetch_commits` replaces the contributors API.
+- The imprint reads "most commits to the printed pages first".
+- `tests/test_pdf_authors.py` has 31 tests; new are printed files, counting and rename following in an
+ isolated git repository.
+- Measured: 661 commits to 638 printed files, 18 authors instead of 19 - `haschek` has no commit to
+ printed content. The order starts with `rpietzsch` (249), `seebi` (156), `sobo` (46),
+ `muddymudskipper` (45) and `irangareddy` (37).
---
-## C2 — Assert every chip anchor resolves — **done**
+## P22 - Cards of equal height - **done**
+
+Spec §11: `cards()` in `tools/pdf/style.typ` lays out its grid row by row and gives both cards of a row
+the height of the taller one; a card alone in the last row keeps its own height. The rounded frame and
+`breakable: false` stay; the screen PDF changes with it.
+
+**Verify:** render the card grids of the part pages in both editions; facing cards end on one line; no
+card breaks across pages.
+**Est:** small. **Depends on:** nothing.
+
+**Done 2026-09-15:** `cards()` measures the cards of each row at the column width and frames both with
+`card-frame()` at the height of the taller one; `card()` now only passes the content through. Checked on
+the card grids of the Build page (p. 11) and the reference (p. 30); the screen PDF keeps its page count
+and text.
+
+---
-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.
+## P23 - Operator reference in a compact format - **done**
+
+Spec §11, D17. A section mode `reference` prints `build/reference/` as one compact, harmonized
+entry per operator; the entries replace the overview tables.
+
+- **Structure:** each type chapter keeps its introduction. Operators follow alphabetically, numbered
+ A.3.x.y and listed in the part contents; the transformer category moves into the entry.
+- **Entry:** the title and a field line with type, category, plugin ID, `Python plugin` and a distance
+ range. Then the rendered description without `## Examples` and without the Python plugin note, its
+ headings as run-in labels. Then one parameter table with the columns Parameter and its ID, Type, Default
+ and Description, an `Advanced` row and `parent.child` sub-parameters, and a `Related:` line with page
+ references.
+- **Sparse data prints as nothing:** no table without parameters, `–` for a missing default, and
+ `see below` with a code block for a multi-line default. The data types map to the vocabulary of spec
+ §11.
+- **Data:** structure and parameters come from `data/plugins.json`, the description from the site page.
+ The build fails when pages and JSON disagree.
+- **Configuration:** `print.yml` switches `build/reference/` from `list` to `reference`. The A.3
+ introduction names the online examples and Python plugins once.
+
+**Verify:** unit tests build entries from a JSON fixture - an operator without parameters, one with
+advanced and sub-parameters, a multi-line default, every data type - and cover the page/JSON check. The
+print build shows 389 entries, no Examples heading and no `None`. The page count is measured against the
+estimate of spec §11 (about 860) and BoD's limit (P14). Render a transformer, a dataset and a custom task
+with more than 20 parameters.
+**Est:** medium to large. **Depends on:** P8.
+
+**Done 2026-09-15:**
+
+- Section mode `reference` in `tools/build_pdf.py`. `reference_section` places a note after the section
+ page and the operators of each type after its overview page, alphabetically, drops the category
+ headings, and fails when pages and `data/plugins.json` disagree. `merge_pages` removes the overview
+ tables.
+- `operator_entries` renders each entry: `operator_fields`; `operator_description` without the title,
+ the Python plugin note and the template sections, with headings as run-in labels; `parameter_table`
+ with the type vocabulary of `data_type`, an `Advanced` row, `parent.child` sub-parameters, `see below`
+ defaults and Markdown descriptions; and `operator_related`.
+- `operator-fields()` in `style.typ` and its mapping in `filter.lua`. `print.yml` switches
+ `build/reference/` to `reference`. `tests/test_build_pdf_reference.py` has 5 tests.
+- Measured: 389 entries with bookmarks; A.3 takes 213 pages (pp. 29-241) and the book 870 (spec §11
+ estimated 860). No `Advanced Parameter` heading and no generated example is left; 9 defaults print as
+ `see below`, 80 entries have a `Related:` line, and 1 web address still points to an operator page.
+ The screen PDF is unchanged.
+- Open: example subsections the plugins write into their own documentation - `5. Example` in Pivot,
+ `6. Examples` in RDF file, `Example usage` in Knowledge Graph - still print as run-in labels with their
+ text; only the generated `## Examples` sections are dropped.
-Cheap: collect the anchor ids from `/tags/` once, then set-compare against the hrefs.
+---
-**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.
+## P24 - Image widths in the sources - **done**
+
+Revised on 2026-09-16, once the report of P15 made the effect visible: the width follows from the density
+an image *prints* at, not from the density it was *captured* at. `dec-tool image-widths`
+(`tools/image_widths.py`) narrows every raster image that prints below 150 ppi in the 16 cm column, in
+pages that are not generated, and leaves the rest as it is.
+
+- **Width:** `pixels / (6.3 inches * 150)`, rounded down to a whole percent - not to a multiple of 5. The
+ width a page already declares cancels out of `declared * density / target`, so the pixels alone decide;
+ one further step down covers the case where the rounding of the density leaves it a pixel short.
+- **Measured at** the width the page declares, quoted or not - two of 187 are written `width=11%` - and an
+ image without one fills the column, so it counts as 100 %.
+- **No floor:** the smallest results are `marketplace-filter-installed.png` at 14 % and
+ `marketplace-filter-package-type.png` at 21 %. Both show a single snippet of a dialog, so the user chose
+ the calculated value over a floor.
+- **Scope:** raster images only; SVGs, remote images and fenced code stay untouched, and generated pages
+ belong to their generators - none of them held an image below the target.
+
+**Verified:** `--fix` wrote 47 widths into 21 pages; the report of P15 is empty afterwards and the book
+shrinks from 872 to 864 pages. The gate was a throwaway PDF of just those images at their new widths.
+Five of the 47 sit in the IDS/OSINT tutorial, which print omits, so they change the site only. rumdl
+clean; the unit tests cover the density, the target width, and the rewriting of quoted, unquoted and
+missing width attributes.
+
+**Superseded:** the first implementation (2026-09-15, D18 as written) took the natural width from the
+capture scale, rounded it to 5 % and skipped anything at 100 %. Its 24 widths stay where the new rule does
+not narrow them further. `task check` runs the check as `check:images`.
---
-## C3 — Tests — **done**
+## P25 - A heading with a single line at the foot of a page - **done**
-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).
+Review finding of 2026-09-16: A.3 Task and Operator Reference sat at the foot of page 29 with its
+intended-audience line under it, and the section started on the next page. A heading is sticky, so it is
+never last on a page - but a one-line lead satisfies that, and the break falls after the line.
-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 the same day: `keep_lead_with_heading` in `tools/build_pdf.py` (print edition only) wraps a paragraph
+that directly follows a heading of level 1 to 4 and is at most 200 characters long in
+`div.keep-with-next`; `filter.lua` maps it to `keep-with-next` in `style.typ`, a sticky block, so the lead
+carries heading and line to the block that follows. Levels 5 and 6 are left out: they are the operator
+entries, which have their own sticky field blocks.
-**Done:** 10 tests reading the override and asserting its slug expression matches
-`tag_slug()`. 59 tests pass in total.
+**Verified:** 304 lead lines wrapped; A.3 now starts a page with its content; across the book, headings
+with at most one line under them at a page foot fall from 23 to 6, and the book grows from 868 to 872
+pages. Measured against levels 1 to 3 alone, which leaves 17 of them at 868 pages: the four pages buy
+eleven fewer stranded headings, and one new one appears (B.2.2.6). `LEAD_HEADINGS` is the one place to
+change if the shorter book matters more. The screen edition does not run the step. One unit test.
---
-## C4 — Documentation — **done**
+## P26 - The first edition is a user guide - **done**
+
+Decided 2026-09-16: the first print edition serves users, so what an administrator or a developer needs
+stays in the online edition. `tools/pdf/print.yml` omits part E (`deploy-and-configure/`, 34 pages) and
+part G (`develop/`, 89 pages) whole, and the narrower `develop/cmem-client-api/` key goes, since section
+keys do not nest.
-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.
+**Verified:** the book drops from 864 to 668 pages and the preflight passes unchanged. No cross-reference
+dies: a link from a printed page into an omitted part becomes a web address in its part's endnote list -
+the JDBC driver setup, for one, prints as
+`https://documentation.eccenca.com/latest/deploy-and-configure/configuration/dataintegration/jdbc/` -
+and internal links without a target fall from 17 to 4, because most of those lived in the dropped trees.
-**Done.**
+**Open:** the imprint's author list counts commits to the printed pages (D16), and 123 pages left the
+book, so `task pdf:authors` should run before the edition goes to print.
---
## Sequencing
+```text
+P1 ──> P2 ──> P3 ──> P4 ──> P5 ──> P7
+ │ │ P6 ──┘
+ ├──> P8 ──> P9 ──────┼─────────────┐
+ ├──> P10 │ │
+ ├──> P11 │ │
+ └──> P12 ──> P15 │ │
+ └──────────────┼──> P13 <────┘
+ └──> P14 ──> P16
+P17 after P8 and P14
+P18 after P8 and P9
+P19 after P3, P20 after P9, P21 after P6 and P18, P22 any time, P23 after P8
+P24 before P15
```
-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..65eef7e49 100644
--- a/tasks/spec.md
+++ b/tasks/spec.md
@@ -1,267 +1,774 @@
-# Spec: temporary tag-listing renderer
-
-**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.
+# Spec: print-on-demand book block
+
+**Status:** accepted 2026-09-14 - all decisions made (§4). Backlog P0-P16 and P18-P26 are implemented;
+P17 (the cover) is open. The backlog's "Done" notes record where the implementation
+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. §11 collects the pull request
+review of 2026-09-15, decided the same day (D15-D18); backlog P19-P24, of which P19-P23 were implemented
+on 2026-09-15: the print edition has 870 pages. P13, P14 and P24, and the tooling of P15, followed the same
+day: PDF/X-4 normalization (§7, with the transparency Ghostscript cannot convert rendered to images), the
+preflight report, image widths in the sources and the list of low-resolution originals. On 2026-09-16 the
+width rule was revised (D18, P24, which closed P15) and a short lead line under a heading became sticky
+(P25). On 2026-09-16 the first edition's scope was set to a user guide, omitting parts E and G whole
+(P26): the print edition has 668 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.
+**Not in scope:** the cover file (front, spine, back); see §9.
+
+The previous content of this file (the temporary tag-listing renderer, removed 2026-09-03) is in the
+git history.
---
-## 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
-
-- **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
-
+
+Several blocks - also inside a list item, a content tab or an admonition.
+
+
+````
+
+- 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.
+
+## 11. Review of the first print build
+
+**Status:** findings of the pull request review, 2026-09-15, decided the same day as D15-D18 (§4).
+P19-P24 and P13 are implemented; P15 keeps 42 images to replace or accept.
+
+| Finding | Today | Proposal | Backlog |
+| :-- | :-- | :-- | :-- |
+| A left-hand page's footer should read `Part A: Build` | `A Build` (R1) | the part label the part band prints | P19 |
+| Web addresses belong at the end of the document | a footnote for each link out of the book (R4) | endnotes, listed at the end of each part (D15) | P20 |
+| The author order should follow the printed content | commits to the whole repository (§3) | commits to the printed pages (D16) | P21 |
+| Facing cards in a two-column grid should be equally high | each card as high as its text | one height per row | P22 |
+| The operator reference should print descriptions and parameters | overview tables, `list` mode (§5) | one compact entry per operator, replacing the overview tables (D17) | P23 |
+| PDF/X-4 and CMYK | specified, not built (§7) | unchanged; done, with transparency rendered to images first | P13 |
+| Low-resolution images mostly lack `width` in the Markdown | an image without `width` prints at its declared density | widths written into the sources so every image prints at 150 ppi or better (D18, revised 2026-09-16); done, and P15 with it | P24, then P15 |
+
+### Footer: the part label
+
+R1 names the part beside the page number of a left-hand page as `A Build`. The part band already prints
+`Part A: Build`, while the contents and the bookmarks print `A Build`. The left-hand footer follows the
+band: `1234 Part A: Build`. The contents, the bookmarks and the right-hand footer stay as they are.
+
+### Web addresses as endnotes (D15)
+
+Today every link out of the book prints its address in a footnote on the same page (R4). Decided:
+
+- A link out of the book prints its text and a superscript number.
+- The numbers run within a part and start again at 1 in the next one. An address cited more than once in
+ a part keeps its first number there.
+- A list "Web addresses", on a new page under an unnumbered heading, closes each part that cites any. It
+ holds each number, its address and the pages that cite it, for example
+ `17 https://github.com/eccenca/cmemc (pp. 412, 530)`.
+- The list is laid out without link annotations, like the contents (P9). Footnotes link marker and entry
+ internally, which Typst cannot switch off, and PDF/X output wants no annotation at all (§7).
+- Links within the book keep their page reference.
+
+Numbering in a `context` rule over every link costs layout passes; measure the compile time and watch
+for Typst's warning that the layout did not converge.
+
+### Author order from the printed content (D16)
+
+Today `tools/pdf/authors.yml` counts a contributor's commits to the whole repository, from the GitHub
+contributors API. Tooling, generated references and content the print edition leaves out weigh as much
+as the printed pages. The repository has 1,633 commits, 1,292 of them touching `docs/`. Decided:
+
+- The printed files: the pages the print edition prints after the section rules (§5, §10), and the
+ images in their directories.
+- The commits: `git log --no-merges` for those files, following renames. A commit counts once, however
+ many printed files it touches.
+- The account of each commit: `author.login` from the GitHub commits API, which maps a commit's e-mail
+ to a GitHub account - one request per 100 commits, about 17.
+- The existing rules stay: no anonymous commits, bots or agents; `authors.exclude`; names from
+ `authors.names` or the profile.
+- `authors.yml` records these counts. The imprint reads "most commits to the printed pages first".
+
+Commits to generated pages credit whoever ran the generator, so they do not count.
+
+### Cards of equal height
+
+`cards()` sets `card()` blocks in two columns, each as high as its text, so facing cards end at
+different heights. Plan: lay the grid out row by row, measure both cards of a row at the column
+width, and give both the taller height. A card alone in the last row keeps its own height. The rounded
+frame and `breakable: false` stay. The screen PDF uses the same grid and changes with it.
+
+### The operator reference in a compact format (D17)
+
+A.3 prints as `list` (§5): the overview pages with their `Name | Description` tables, while the 389
+operator pages are dropped. The review asks for the operators themselves, with their descriptions and
+parameters but without examples. In the print edition the operator entries replace the overview tables.
+The format below was decided as proposed (D17).
+
+All 389 operators share one structure. `tools/templates/plugin.md` generates their pages, and the
+generator dumps the same data to `data/plugins.json`, which is tracked and was last regenerated together
+with the pages on 2026-09-02. Much of the structure is sparse (measured 2026-09-15):
+
+| Part | Measured |
+| :-- | :-- |
+| parameters | 1,040 in all; 59 operators have none, 316 have no advanced ones. Median per operator: 4 for custom workflow tasks (at most 24), 3 for datasets, 1 for distance measures and transformers, 0 for aggregators |
+| defaults | 431 parameters have none; 8 are multi-line, 4 structured |
+| parameter descriptions | 27 are empty |
+| sub-parameters | 21 |
+| descriptions | median 263 characters; 154 under 200, 43 over 2,000 |
+| examples | on 91 operators, 19 % of the documentation text |
+| related plugins | on 81 operators |
+| Python plugins | 71 |
+
+A generated page spends a heading and three bullets on every parameter (`ID`, `Datatype`,
+`Default Value`). It writes `None` both for a missing default and for an empty advanced section, and
+repeats the Python plugin note on every Python operator.
+
+#### Structure
+
+- **Chapters:** each operator type keeps its chapter and its introduction, A.3.1 Aggregators to A.3.5
+ Transformers. The chapter's operator entries replace its overview table.
+- **Order:** operators follow in alphabetical order, as in the overview tables. The transformer category
+ becomes a field of the entry instead of a level of headings. So every operator sits at the same level,
+ A.3.x.y, and the part contents list them with their pages.
+- **Introduction:** the introduction of A.3 says once what an entry shows, that the examples are part of
+ the online edition, and what a Python plugin needs.
+
+#### An entry
+
+```text
+A.3.5.42 Constant transformer · Value · constant
+
+Generates a constant value.
+
+Parameter Type Default Description
+Value text – The constant value to be generated
+value
```
-`| url` resolves `tags/` relative to the current page, so it survives mike's versioned
-prefixes without special handling.
-
-### Open questions
-
-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.
-
-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.
-
-3. **`hide: tags` pages.** The existing template already honours it; the override must keep
- that branch. No decision needed, just do not drop it.
+- **Heading:** the operator's title, numbered, kept together with what follows.
+- **Field line:** the operator type, the transformer category, the plugin ID in monospace, `Python plugin`
+ where it applies, and a distance measure's range. It is small, grey and one line long.
+- **Description:** the rendered description from the site page, without its `## Examples` section and
+ without the Python plugin note. Its own second-level headings, such as `## Characteristics`, print as
+ run-in labels, not as numbered sections.
+- **Parameters:** one table per operator, in the plugin's order. The columns are Parameter (the title, and
+ the ID in monospace below it), Type, Default and Description. Advanced parameters follow in the same
+ table after a row labelled `Advanced`; a sub-parameter follows its parameter as `parent.child`.
+- **Related:** one line, for example `Related: Merge (p. 214), Zip (p. 230)`.
+
+Sparse data prints as nothing:
+
+- An operator without parameters has no table, and one without advanced parameters no `Advanced` row.
+- A parameter without a default shows `–`; a password never shows one.
+- A multi-line or structured default shows `see below`, and a code block follows the table.
+- An operator without related plugins has no `Related` line, and an empty parameter description leaves
+ its cell empty.
+
+The data types print in one vocabulary:
+
+| Data type | Printed |
+| :-- | :-- |
+| `string`, `multiline string` | text |
+| `int`, `Long` | integer |
+| `double` | number |
+| `boolean` | boolean |
+| `char` | character |
+| `enumeration` | choice |
+| `password` | password |
+| `resource` | file |
+| `scheme:string` | URI - to confirm |
+| `traversable[string]` | list of text |
+| `stringmap` | map |
+| `code-sparql` | SPARQL |
+
+#### Data source
+
+The build takes the structure and the parameters from `data/plugins.json` and only the description from
+the rendered site page. The JSON tells a missing default apart from the text `None`, and it carries the
+types, the advanced flags and the sub-parameters that the page shows only as bullets. The build fails when
+an operator page and the JSON disagree - an operator without an entry, or an entry without a page - so a
+regeneration that updated only one of them shows.
+
+#### Budget
+
+A rough count - 95 characters per line of description, one row per parameter and more for a long
+description, four lines for heading and field line - gives about 180 pages for the 389 entries. The part
+contents add about 8 pages. The book would grow from 666 to about 860 pages; the operator pages in full
+took about 458 (§5). BoD takes 1,200 pages on 80 g and 1,050 on 90 g (D5). Measured after P23: A.3 takes 213 pages and the
+book 870.
+
+The site and the generator stay as they are. The same format could later serve the site as well; that is
+not part of this proposal.
+
+### PDF/X-4 and CMYK
+
+Unchanged: the optional normalization pass (§7, D11), backlog P13. The review confirms that it is
+needed.
+
+### Image widths in the sources (D18)
+
+Typst prints an image without `width` at the pixel density it declares, 72 dpi when it declares none,
+and never wider than the column. On 2026-09-15 the printed pages that are not generated hold 500 raster
+images, 352 of them without `width`. The declared densities: none on 231, 144 dpi on 180 (Retina
+captures on macOS), 120 dpi on 80, 96 dpi on 8, 192 dpi on 1. 43 images print below 150 ppi, 37 of them
+without `width`.
+
+Decided as D18 on 2026-09-15 from the capture density, and **revised on 2026-09-16** once the report of
+P15 showed what each image actually prints at. The rule is the printed density: `dec-tool image-widths`,
+with `--fix`, narrows every raster image that prints below 150 ppi in pages that are not generated.
+
+- **Width:** the pixel width divided by the 6.3 inches of the text column and by the 150 ppi target,
+ rounded **down to a whole percent**, with one further step down where the rounding of the density would
+ leave it short. The width a page declares cancels out of `declared × density ÷ target`, so the pixels
+ alone decide it.
+- **Measured at** the width the page declares, quoted or not; an image without one fills the column and
+ counts as 100 %.
+- **Scope:** raster images only. SVGs, remote images, fenced code and images already at 150 ppi or more
+ stay as they are, as do generated pages.
+- **No floor:** an image whose pixels only support a thumbnail gets the thumbnail; the two smallest are
+ 14 % and 21 %, each a snippet of a dialog.
+- **Check:** `task check` runs it as `check:images`, so a new screenshot is caught before it is built.
+
+The same percentage serves the site and the PDF: on paper the image prints at 150 ppi or better, and the
+site shows it at the share of the article it fills on the page. This closes P15 - `--fix` wrote 47 widths
+into 21 pages and the report is empty - rather than replacing screenshots. The superseded rule, natural
+width from the capture scale rounded to 5 %, left 42 images below the target.
+
+### Decisions
+
+Decided on 2026-09-15 as D15-D18, recorded in §4: web addresses in a list at the end of each part (D15),
+the author order from commits to the printed pages (D16), the compact operator reference as proposed
+(D17), and screenshot widths against the full page width (D18).
diff --git a/tests/test_build_pdf.py b/tests/test_build_pdf.py
index 639731c53..38eb1eb6d 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):
@@ -73,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) == (
'