brevier 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. brevier-0.1.0/.githooks/commit-msg +32 -0
  2. brevier-0.1.0/.github/ISSUE_TEMPLATE/bug.md +52 -0
  3. brevier-0.1.0/.github/ISSUE_TEMPLATE/epic.md +39 -0
  4. brevier-0.1.0/.github/ISSUE_TEMPLATE/task.md +52 -0
  5. brevier-0.1.0/.github/ISSUE_TEMPLATE/user-story.md +71 -0
  6. brevier-0.1.0/.github/PULL_REQUEST_TEMPLATE.md +59 -0
  7. brevier-0.1.0/.github/workflows/cross-os-proof.yml +133 -0
  8. brevier-0.1.0/.github/workflows/pip-install-proof.yml +220 -0
  9. brevier-0.1.0/.github/workflows/release.yml +45 -0
  10. brevier-0.1.0/.gitignore +29 -0
  11. brevier-0.1.0/AGENTS.md +172 -0
  12. brevier-0.1.0/CLAUDE.md +1 -0
  13. brevier-0.1.0/PKG-INFO +12 -0
  14. brevier-0.1.0/brevier/__init__.py +1 -0
  15. brevier-0.1.0/brevier/cli/__init__.py +129 -0
  16. brevier-0.1.0/brevier/css_layers/__init__.py +66 -0
  17. brevier-0.1.0/brevier/engine/__init__.py +53 -0
  18. brevier-0.1.0/brevier/engine/weasyprint_engine.py +57 -0
  19. brevier-0.1.0/brevier/pipeline/__init__.py +117 -0
  20. brevier-0.1.0/docs/PRD.md +793 -0
  21. brevier-0.1.0/docs/architecture/adr/001-install-channels.md +57 -0
  22. brevier-0.1.0/docs/architecture/adr/002-identical-output.md +133 -0
  23. brevier-0.1.0/docs/architecture/adr/003-component-architecture.md +98 -0
  24. brevier-0.1.0/docs/architecture/adr/004-product-name.md +35 -0
  25. brevier-0.1.0/docs/process/DEFINITION_OF_DONE.md +40 -0
  26. brevier-0.1.0/docs/process/DEFINITION_OF_READY.md +20 -0
  27. brevier-0.1.0/docs/process/METHODOLOGY.md +38 -0
  28. brevier-0.1.0/docs/process/NON_FUNCTIONAL_REQUIREMENTS.md +38 -0
  29. brevier-0.1.0/docs/process/PRODUCT_GOAL.md +31 -0
  30. brevier-0.1.0/docs/process/SCRUM_EVENTS.md +44 -0
  31. brevier-0.1.0/pyproject.toml +38 -0
  32. brevier-0.1.0/scripts/check_name.py +401 -0
  33. brevier-0.1.0/tests/architecture/test_engine_is_hidden.py +33 -0
  34. brevier-0.1.0/tests/cli/test_render_command.py +101 -0
  35. brevier-0.1.0/tests/compare_pdf_content.py +116 -0
  36. brevier-0.1.0/tests/cross_os_check.py +120 -0
  37. brevier-0.1.0/tests/css/__init__.py +0 -0
  38. brevier-0.1.0/tests/css/test_layer_order.py +128 -0
  39. brevier-0.1.0/tests/fixtures/hello-bundled.html +23 -0
  40. brevier-0.1.0/tests/fixtures/hello.css +47 -0
  41. brevier-0.1.0/tests/fixtures/hello.html +12 -0
  42. brevier-0.1.0/tests/packaging/check_pdf.py +41 -0
  43. brevier-0.1.0/tests/packaging/test_pyproject_pins.py +58 -0
  44. brevier-0.1.0/tests/pipeline/conftest.py +49 -0
  45. brevier-0.1.0/tests/pipeline/test_debug_output.py +108 -0
  46. brevier-0.1.0/tests/pipeline/test_emit_html_writes_both_files.py +77 -0
  47. brevier-0.1.0/tests/scripts/test_check_name.py +263 -0
  48. brevier-0.1.0/uv.lock +967 -0
@@ -0,0 +1,32 @@
1
+ #!/bin/sh
2
+ # Enforces AGENTS.md's commit conventions: subject must reference a real
3
+ # issue number, subject must be short, body must stay short (a one-line
4
+ # reason at most, not a rewritten copy of the issue). Installed via
5
+ # `git config core.hooksPath .githooks` (see README/CONTRIBUTING).
6
+ set -eu
7
+
8
+ MSG_FILE="$1"
9
+ SUBJECT=$(sed -n '1p' "$MSG_FILE")
10
+ SUBJECT_LEN=$(printf '%s' "$SUBJECT" | wc -c)
11
+
12
+ # Body = all non-blank lines after the first blank line following the subject.
13
+ BODY_LINES=$(awk 'NR==1{next} /^$/{seen_blank=1; next} seen_blank && NF{count++} END{print count+0}' "$MSG_FILE")
14
+
15
+ FAIL=0
16
+
17
+ if ! printf '%s' "$SUBJECT" | grep -qE '#[0-9]+'; then
18
+ echo "commit-msg: subject line must reference a real issue number, e.g. 'feat: add hyphenation ranking (#12)'" >&2
19
+ FAIL=1
20
+ fi
21
+
22
+ if [ "$SUBJECT_LEN" -gt 73 ]; then
23
+ echo "commit-msg: subject line is $SUBJECT_LEN chars, keep it under ~72" >&2
24
+ FAIL=1
25
+ fi
26
+
27
+ if [ "$BODY_LINES" -gt 5 ]; then
28
+ echo "commit-msg: commit body has $BODY_LINES content lines — keep it to a short reason, not a rewritten issue. Long explanation belongs in the issue, not the commit." >&2
29
+ FAIL=1
30
+ fi
31
+
32
+ exit $FAIL
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: Bug
3
+ about: Something is broken, with a known or suspected reproduction
4
+ title: '[BUG] '
5
+ labels: bug
6
+ ---
7
+
8
+ <!-- Before opening this issue:
9
+ Short sentences, plain words, facts and numbers. No filler.
10
+ Labels: one type (set by this template) and one priority, P0-critical
11
+ to P3-low. Assign the issue. The sprint is the milestone: sprint-N
12
+ labels are retired, do not add one.
13
+ Project board: set Status, Priority, Size, Estimate, Start date and
14
+ Target date. Size and Estimate come from the points or hours below. -->
15
+
16
+ ## Bug
17
+ <!-- What is broken and how to see it: reproduction steps, a command, or a
18
+ log excerpt. Say whether it is confirmed live or only suspected. -->
19
+
20
+ ## Root Cause
21
+ <!-- Fill in once known. Delete this section while still investigating. -->
22
+
23
+ ## Severity vs. Priority
24
+ <!-- Two axes, set independently. A low-severity bug can be P0 when it
25
+ blocks a release. A Blocker-severity bug in dead code can be P3.
26
+
27
+ The priority written here and the P0-critical to P3-low label on the
28
+ issue must agree. -->
29
+ **Severity (technical impact):** Blocker / Critical / Major / Minor
30
+ **Priority (business urgency):** P0 / P1 / P2 / P3
31
+
32
+ ## Acceptance Criteria
33
+ <!-- At most 5, each independently checkable, each naming the test or
34
+ command that proves it. A date is not a proof.
35
+
36
+ Two more rules for a Bug specifically:
37
+ "Fixed and verified live" means you drove the broken path and watched
38
+ it work, not that the diff looks right. If the bug is in generated
39
+ code, verify against the golden-project build, not a template unit
40
+ test alone.
41
+ The regression test is the criterion most often ticked without the
42
+ test existing. Name it, and break the fix once to watch it fail. -->
43
+ - [ ] Fixed, and the fixed path verified through the layer the bug was in
44
+ (`<test or command>`)
45
+ - [ ] Regression test added, seen failing before it passed (`<test name>`)
46
+
47
+ ## Notes
48
+ <!-- Decisions, rescopes, follow-up issue numbers. Delete if there are
49
+ none. Not a running log.
50
+
51
+ Bugs sit outside the Epic to Story to Task hierarchy, so there is no
52
+ parent section here by design. -->
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: Epic
3
+ about: A large body of work spanning several sprints, broken into Stories
4
+ title: '[EPIC] '
5
+ labels: epic
6
+ ---
7
+
8
+ <!-- Before opening this issue:
9
+ Short sentences, plain words, facts and numbers. No filler.
10
+ Labels: one type (set by this template) and one priority, P0-critical
11
+ to P3-low. Assign the issue. The sprint is the milestone: sprint-N
12
+ labels are retired, do not add one.
13
+ Project board: set Status, Priority, Size, Estimate, Start date and
14
+ Target date. Size and Estimate come from the points or hours below. -->
15
+
16
+ ## Epic
17
+ <!-- The outcome this epic delivers. Name what exists at the end, not the
18
+ slot in the schedule it occupies. -->
19
+
20
+ ## Business Value
21
+ <!-- Why it matters, in one or two sentences. -->
22
+
23
+ ## Product Goal alignment
24
+ <!-- Which Product Goal (docs/process/PRODUCT_GOAL.md) this serves.
25
+ "Indirect: ..." is a valid answer for tooling and maintenance work.
26
+ Write that rather than deleting the section. -->
27
+
28
+ ## Closing this Epic
29
+ <!-- Child Stories are the native sub-issue links and nothing else. Add
30
+ them from the Sub-issues panel on this Epic. There is no markdown list
31
+ here on purpose — it duplicates the panel and drifts.
32
+
33
+ Do not close this Epic while a sub-issue is open. Do not leave it open
34
+ once they are all closed. Tasks hang off Stories, never off an Epic. A
35
+ Story that was dropped rather than delivered gets one line in Notes. -->
36
+
37
+ ## Notes
38
+ <!-- Decisions, rescopes, follow-up issue numbers. Delete this section if
39
+ there are none. Not a running log. -->
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: Task
3
+ about: A technical subtask under a Story, or standalone technical work
4
+ title: '[TASK] '
5
+ labels: task
6
+ ---
7
+
8
+ <!-- Before opening this issue:
9
+ Short sentences, plain words, facts and numbers. No filler.
10
+ Labels: one type (set by this template) and one priority, P0-critical
11
+ to P3-low. Assign the issue. The sprint is the milestone: sprint-N
12
+ labels are retired, do not add one.
13
+ Project board: set Status, Priority, Size, Estimate, Start date and
14
+ Target date. Size and Estimate come from the points or hours below. -->
15
+
16
+ ## Task
17
+ <!-- What needs doing, and why, in a few sentences or bullets. -->
18
+
19
+ ## No parent
20
+ <!-- Delete this whole section when the Task has a Story. The native
21
+ sub-issue link is the parent record; a markdown copy is a second thing
22
+ to keep in sync.
23
+
24
+ Keep it only for a deliberate "no parent" decision, replacing this
25
+ comment with the one-line reason.
26
+
27
+ Set the parent from the Story: Sub-issues, Add existing issue. -->
28
+
29
+ ## Scope
30
+ <!-- Only when it is not obvious what is excluded. Name the sibling issue
31
+ that owns anything deliberately left out. Delete otherwise. -->
32
+
33
+ ## Acceptance Criteria
34
+ <!-- At most 5, each independently checkable, each naming the test or
35
+ command that proves it. A date is not a proof.
36
+
37
+ - [ ] `npm run compile` succeeds with the new command registered
38
+ (`extension.activation.test.ts`)
39
+
40
+ Prefer a test CI already runs. If nothing can prove it, say so:
41
+ (manual: exact steps) or (no test: reason). One manual criterion per
42
+ issue at most. Do not close with a box unchecked. -->
43
+ - [ ] <criterion> (`<test or command>`)
44
+ - [ ] <criterion> (`<test or command>`)
45
+
46
+ ## Estimate
47
+ **Hours:** <hours, not points. Points size a Story; hours size the work of
48
+ implementing one Task inside a sprint.>
49
+
50
+ ## Notes
51
+ <!-- Decisions, rescopes, follow-up issue numbers. Delete this section if
52
+ there are none. Not a running log. -->
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: User Story
3
+ about: A vertical slice of user-facing value, the real requirements unit
4
+ title: '[STORY] As a <role>, I want <goal> so that <benefit>'
5
+ labels: user-story
6
+ ---
7
+
8
+ <!-- Before opening this issue:
9
+ Short sentences, plain words, facts and numbers. No filler.
10
+ Labels: one type (set by this template) and one priority, P0-critical
11
+ to P3-low. Assign the issue. The sprint is the milestone: sprint-N
12
+ labels are retired, do not add one.
13
+ Project board: set Status, Priority, Size, Estimate, Start date and
14
+ Target date. Size and Estimate come from the points or hours below. -->
15
+
16
+ ## User Story
17
+ **As a** <role>
18
+ **I want** <goal>
19
+ **So that** <benefit>
20
+
21
+ <!-- INVEST before this enters the backlog: Independent, Negotiable,
22
+ Valuable, Estimable, Small, Testable. If it fails Small or Estimable,
23
+ split it rather than forcing a sixth criterion. -->
24
+
25
+ ## No parent
26
+ <!-- Delete this whole section when the Story has an Epic. The native
27
+ sub-issue link is the parent record; a markdown copy is a second thing
28
+ to keep in sync.
29
+
30
+ Keep it only for a deliberate "no parent" decision, replacing this
31
+ comment with the one-line reason.
32
+
33
+ Set the parent from the Epic: Sub-issues, Add existing issue. -->
34
+
35
+ ## Acceptance Criteria (Given/When/Then)
36
+ <!-- At most 5, each independently checkable.
37
+
38
+ Every criterion names the test or command that proves it, in brackets
39
+ at the end:
40
+
41
+ - [ ] Given an invoice with 60 rows, when it renders, then the
42
+ table header repeats on page 2 (`test_table_header_repeats`)
43
+
44
+ A date is not a proof. Prefer a test CI already runs. A repeatable
45
+ command is acceptable when a test genuinely cannot cover it. If
46
+ nothing can prove it, say so: (manual: exact steps) or (no test:
47
+ reason). One manual criterion per issue at most.
48
+
49
+ Do not close with a box unchecked. Check each one against the code,
50
+ not against what this issue used to say. -->
51
+ - [ ] Given <context>, when <action>, then <outcome> (`<test or command>`)
52
+ - [ ] Given <context>, when <action>, then <outcome> (`<test or command>`)
53
+
54
+ ## Definition of Ready
55
+ - [ ] Meets [Definition of Ready](https://github.com/liviuionesi/brevier/blob/develop/docs/process/DEFINITION_OF_READY.md)
56
+
57
+ ## Story Points
58
+ **Estimate:** <Fibonacci: 1 / 2 / 3 / 5 / 8 / 13 / 21>
59
+ <!-- The sprint is the GitHub Milestone. There is no Sprint section here. -->
60
+
61
+ ## Closing this Story
62
+ <!-- Technical Tasks are the native sub-issue links and nothing else, for
63
+ the same reason as the Epic template. Do not close this Story while a
64
+ child Task is open. -->
65
+
66
+ ## Definition of Done
67
+ - [ ] Meets [Definition of Done](https://github.com/liviuionesi/brevier/blob/develop/docs/process/DEFINITION_OF_DONE.md)
68
+
69
+ ## Notes
70
+ <!-- Decisions, rescopes, follow-up issue numbers. Delete this section if
71
+ there are none. Not a running log. -->
@@ -0,0 +1,59 @@
1
+ # Pull Request
2
+
3
+ ## Description
4
+ <!-- What changed, in a few lines. -->
5
+
6
+ ## Related Issue
7
+ Closes #<!-- issue number -->
8
+
9
+ ## Type of Change
10
+ - [ ] 🐛 Bug fix
11
+ - [ ] ✨ New feature
12
+ - [ ] 💥 Breaking change
13
+ - [ ] 📝 Documentation
14
+ - [ ] 🔧 Configuration
15
+ - [ ] ♻️ Refactoring
16
+ - [ ] ✅ Test improvement
17
+
18
+ ## Area(s) Affected
19
+ - [ ] Composer (classes, template or PDF in, schema out)
20
+ - [ ] Preparer (data cleaning, hyphenation, binding)
21
+ - [ ] Engine (layout, pagination, PDF out)
22
+ - [ ] Themes (DIN 5008, web)
23
+ - [ ] PDF import
24
+ - [ ] Linter
25
+ - [ ] CLI
26
+ - [ ] Java client
27
+ - [ ] Packaging / CI
28
+ - [ ] Documentation
29
+
30
+ ## Testing Performed
31
+ - [ ] Tests added or updated (`pytest`, `./gradlew test`)
32
+ - [ ] Golden render gate checked, if this can move a pixel
33
+ (`docs/process/DEFINITION_OF_DONE.md`)
34
+ - [ ] Output opened and looked at, not only asserted
35
+
36
+ ## Code Quality
37
+ - [ ] `ruff check` and `ruff format --check` clean
38
+ - [ ] `mypy` clean for touched code
39
+ - [ ] Self-review done
40
+ - [ ] Docstrings / Javadoc per `AGENTS.md`
41
+ - [ ] No debug prints left behind
42
+ - [ ] Errors handled, not swallowed
43
+
44
+ ## Documentation
45
+ - [ ] README updated, if needed
46
+ - [ ] `docs/PRD.md` updated with a version history row, if the product changed
47
+ - [ ] New architecture decision written as an ADR under `docs/architecture/adr/`
48
+
49
+ ## Security
50
+ - [ ] No secrets in code, logs or debug output
51
+ - [ ] No customer data written anywhere by default (PRD risk R8)
52
+ - [ ] No font file extracted or redistributed (PRD risk R7)
53
+ - [ ] Dependency scan clean for new or changed dependencies
54
+
55
+ ## Before Merge
56
+ - [ ] CI passing
57
+ - [ ] Review comments addressed
58
+ - [ ] Branch up to date with `develop`
59
+ - [ ] Issue resynced to real implementation state
@@ -0,0 +1,133 @@
1
+ # Does the engine actually render on Linux, macOS and Windows?
2
+ #
3
+ # ADR 002 commits Brevier to all three operating systems, but only Linux was
4
+ # ever tested. This workflow answers the question for the other two before
5
+ # the product is built on the assumption.
6
+ #
7
+ # Manual trigger only. macOS runner minutes bill at 10x and Windows at 2x on
8
+ # a private repository, so this must never fire on every push.
9
+ name: cross-os-proof
10
+
11
+ on:
12
+ workflow_dispatch:
13
+
14
+ jobs:
15
+ render:
16
+ name: render on ${{ matrix.os }}
17
+ runs-on: ${{ matrix.os }}
18
+ strategy:
19
+ # One platform failing must not hide the result of the other two —
20
+ # the whole point is to learn where it breaks.
21
+ fail-fast: false
22
+ matrix:
23
+ os: [ubuntu-latest, macos-latest, windows-latest]
24
+
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: "3.13"
31
+
32
+ # --- the native font stack, obtained the way each platform does it ---
33
+
34
+ - name: Linux — font stack from apt
35
+ if: runner.os == 'Linux'
36
+ run: |
37
+ sudo apt-get update -qq
38
+ sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 \
39
+ libharfbuzz0b libfontconfig1 fonts-dejavu-core
40
+
41
+ - name: macOS — font stack from Homebrew
42
+ if: runner.os == 'macOS'
43
+ run: |
44
+ brew install pango
45
+ echo "DYLD_FALLBACK_LIBRARY_PATH=$(brew --prefix)/lib" >> "$GITHUB_ENV"
46
+
47
+ - name: Windows — font stack from MSYS2
48
+ if: runner.os == 'Windows'
49
+ uses: msys2/setup-msys2@v2
50
+ id: msys2
51
+ with:
52
+ msystem: UCRT64
53
+ update: true
54
+ install: mingw-w64-ucrt-x86_64-pango
55
+
56
+ - name: Windows — tell WeasyPrint where the DLLs are
57
+ if: runner.os == 'Windows'
58
+ shell: bash
59
+ run: |
60
+ # WEASYPRINT_DLL_DIRECTORIES is WeasyPrint's own supported way to
61
+ # point at the native libraries, which is why Windows needs no
62
+ # equivalent of the Linux runtime hook in ADR 001.
63
+ echo "WEASYPRINT_DLL_DIRECTORIES=${{ steps.msys2.outputs.msys2-location }}\\ucrt64\\bin" >> "$GITHUB_ENV"
64
+
65
+ # --- the engine itself, pinned so the comparison means something ---
66
+
67
+ - name: Install WeasyPrint
68
+ shell: bash
69
+ run: python -m pip install --quiet "weasyprint==70.0" "pypdf>=5,<7"
70
+
71
+ - name: Fetch identical font files, pinned by commit
72
+ shell: bash
73
+ env:
74
+ # A fixed commit in google/fonts, so the font bytes cannot move.
75
+ FONTS_COMMIT: 8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5
76
+ SANS_SHA256: bfb7bb691513f12e734dc346c03a03f784912432d7e3fa8e56efcf906fe86b3d
77
+ SERIF_SHA256: 4d8e6761424656867019081a1a01336f3cb086982682698714054fc33f782713
78
+ run: |
79
+ mkdir -p tests/fixtures/fonts
80
+ base="https://raw.githubusercontent.com/google/fonts/$FONTS_COMMIT/ofl"
81
+ curl -sSL -o tests/fixtures/fonts/NotoSans.ttf "$base/notosans/NotoSans%5Bwdth%2Cwght%5D.ttf"
82
+ curl -sSL -o tests/fixtures/fonts/NotoSerif.ttf "$base/notoserif/NotoSerif%5Bwdth%2Cwght%5D.ttf"
83
+ # Prove the input was byte-identical here, so a difference in the
84
+ # output later cannot be blamed on a different font file.
85
+ digest() { python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$1"; }
86
+ sans=$(digest tests/fixtures/fonts/NotoSans.ttf)
87
+ serif=$(digest tests/fixtures/fonts/NotoSerif.ttf)
88
+ echo " NotoSans.ttf : $sans"
89
+ echo " NotoSerif.ttf: $serif"
90
+ test "$sans" = "$SANS_SHA256" || { echo "NotoSans differs on this runner"; exit 1; }
91
+ test "$serif" = "$SERIF_SHA256" || { echo "NotoSerif differs on this runner"; exit 1; }
92
+ echo "font input is identical on this runner"
93
+
94
+ - name: Render with the platform's own fonts
95
+ shell: bash
96
+ run: python tests/cross_os_check.py tests/fixtures/hello.html "out-system-${{ runner.os }}.pdf"
97
+
98
+ - name: Render with identical bundled fonts
99
+ shell: bash
100
+ run: python tests/cross_os_check.py tests/fixtures/hello-bundled.html "out-bundled-${{ runner.os }}.pdf"
101
+
102
+ - uses: actions/upload-artifact@v4
103
+ with:
104
+ name: pdf-${{ runner.os }}
105
+ path: out-*.pdf
106
+
107
+ compare:
108
+ name: compare the three PDFs
109
+ needs: render
110
+ if: always()
111
+ runs-on: ubuntu-latest
112
+ steps:
113
+ - uses: actions/download-artifact@v4
114
+ with:
115
+ path: pdfs
116
+
117
+ - name: Checksums across operating systems
118
+ run: |
119
+ for kind in system bundled; do
120
+ echo "=============================================================="
121
+ if [ "$kind" = system ]; then
122
+ echo "Each platform used ITS OWN fonts:"
123
+ else
124
+ echo "Every platform used the SAME font files:"
125
+ fi
126
+ find pdfs -name "out-$kind-*.pdf" -print0 | sort -z | xargs -0 sha256sum
127
+ count=$(find pdfs -name "out-$kind-*.pdf" | wc -l)
128
+ unique=$(find pdfs -name "out-$kind-*.pdf" -print0 | xargs -0 sha256sum | awk '{print $1}' | sort -u | wc -l)
129
+ echo "-> $count PDFs, $unique distinct checksum(s)."
130
+ echo
131
+ done
132
+ echo "Reminder: identical bytes need identical fonts AND identical"
133
+ echo "native library versions. See ADR 003."
@@ -0,0 +1,220 @@
1
+ # Does `pip install brevier` give a working `brevier` command?
2
+ #
3
+ # Epic #38's proof: in a folder holding only hello.html and hello.css,
4
+ # `brevier render hello.html -o hello.pdf` writes hello.pdf into that same
5
+ # folder. This workflow builds the wheel once, then runs that proof on
6
+ # Linux, macOS and Windows, on Python 3.10 (the floor) and 3.13, in a fresh
7
+ # virtual environment with nothing else from this repo. `brevier --version`
8
+ # alone does not count — it works even without Pango (ADR 001).
9
+ #
10
+ # It also renders hello-bundled.html (fonts pinned with @font-face, one
11
+ # Python version, one run per OS) and compares those three PDFs' content.
12
+ # hello.html cannot be compared this way: without a pinned font, each OS
13
+ # resolves "Noto Sans" to a different substitute and draws a different page
14
+ # (ADR 002, "Fonts decide the result") — that is real, not a bug, so the
15
+ # content check needs the fixture that removes it.
16
+ #
17
+ # `source: pypi` swaps the wheel for a real `pip install brevier` from
18
+ # PyPI, to prove a tagged release too, not just the local build.
19
+ #
20
+ # Manual trigger and version tags only: macOS runner minutes bill at 10x
21
+ # and Windows at 2x on a private repo (see cross-os-proof.yml).
22
+ name: pip-install-proof
23
+
24
+ on:
25
+ workflow_dispatch:
26
+ inputs:
27
+ source:
28
+ description: "Install brevier from the wheel this run builds, or from PyPI"
29
+ type: choice
30
+ options: [wheel, pypi]
31
+ default: wheel
32
+ push:
33
+ tags:
34
+ - "v*.*.*"
35
+
36
+ env:
37
+ # A fixed date, so two runs of the same input cannot differ by timestamp
38
+ # (ADR 002, rule 2).
39
+ SOURCE_DATE_EPOCH: "1700000000"
40
+ # pypdf pin shared with cross-os-proof.yml, for the same reason: only the
41
+ # check scripts use it, so an unpinned range should never be why a run
42
+ # fails.
43
+ PYPDF_SPEC: "pypdf>=5,<7"
44
+
45
+ jobs:
46
+ build:
47
+ name: build the wheel
48
+ runs-on: ubuntu-latest
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+
52
+ - uses: actions/setup-python@v5
53
+ with:
54
+ python-version: "3.13"
55
+
56
+ - name: Build wheel and sdist
57
+ run: |
58
+ python -m pip install --quiet build
59
+ python -m build
60
+
61
+ - uses: actions/upload-artifact@v4
62
+ with:
63
+ name: wheel
64
+ path: dist/*.whl
65
+ if-no-files-found: error
66
+
67
+ install:
68
+ name: install on ${{ matrix.os }}, Python ${{ matrix.python-version }}
69
+ needs: build
70
+ runs-on: ${{ matrix.os }}
71
+ strategy:
72
+ # One combination failing must not hide the result of the other five.
73
+ fail-fast: false
74
+ matrix:
75
+ os: [ubuntu-latest, macos-latest, windows-latest]
76
+ python-version: ["3.10", "3.13"]
77
+
78
+ steps:
79
+ - uses: actions/checkout@v4
80
+
81
+ - uses: actions/setup-python@v5
82
+ with:
83
+ python-version: ${{ matrix.python-version }}
84
+
85
+ # --- Pango, obtained the way each platform does it (ADR 001) ---
86
+
87
+ - name: Linux — font stack from apt
88
+ if: runner.os == 'Linux'
89
+ run: |
90
+ sudo apt-get update -qq
91
+ sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 \
92
+ libharfbuzz0b libfontconfig1 fonts-dejavu-core
93
+
94
+ - name: macOS — font stack from Homebrew
95
+ if: runner.os == 'macOS'
96
+ run: |
97
+ brew install pango
98
+ echo "DYLD_FALLBACK_LIBRARY_PATH=$(brew --prefix)/lib" >> "$GITHUB_ENV"
99
+
100
+ - name: Windows — font stack from MSYS2
101
+ if: runner.os == 'Windows'
102
+ uses: msys2/setup-msys2@v2
103
+ id: msys2
104
+ with:
105
+ msystem: UCRT64
106
+ update: true
107
+ install: mingw-w64-ucrt-x86_64-pango
108
+
109
+ - name: Windows — tell WeasyPrint where the DLLs are
110
+ if: runner.os == 'Windows'
111
+ shell: bash
112
+ run: |
113
+ # WEASYPRINT_DLL_DIRECTORIES is WeasyPrint's own supported way to
114
+ # point at the native libraries (ADR 001).
115
+ echo "WEASYPRINT_DLL_DIRECTORIES=${{ steps.msys2.outputs.msys2-location }}\\ucrt64\\bin" >> "$GITHUB_ENV"
116
+
117
+ # --- a fresh virtual environment, with nothing else from this repo ---
118
+
119
+ - name: Create the virtual environment
120
+ shell: bash
121
+ run: |
122
+ python -m venv .venv
123
+ # Windows' venv lays out Scripts/, not bin/ — record whichever
124
+ # this OS used, as an absolute path so it survives a later `cd`.
125
+ if [ -d "$PWD/.venv/bin" ]; then
126
+ echo "VENV_BIN=$PWD/.venv/bin" >> "$GITHUB_ENV"
127
+ else
128
+ echo "VENV_BIN=$PWD/.venv/Scripts" >> "$GITHUB_ENV"
129
+ fi
130
+
131
+ # --- install brevier itself, from the wheel or from PyPI ---
132
+
133
+ - uses: actions/download-artifact@v4
134
+ if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.source == 'pypi') }}
135
+ with:
136
+ name: wheel
137
+ path: dist
138
+
139
+ - name: Install brevier from the wheel this run built
140
+ if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.source == 'pypi') }}
141
+ shell: bash
142
+ run: '"$VENV_BIN/python" -m pip install --quiet dist/*.whl "$PYPDF_SPEC"'
143
+
144
+ - name: Install brevier from PyPI
145
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.source == 'pypi' }}
146
+ shell: bash
147
+ run: '"$VENV_BIN/python" -m pip install --quiet brevier "$PYPDF_SPEC"'
148
+
149
+ # --- the proof itself: render, in a folder holding only the fixtures ---
150
+
151
+ - name: Render in a folder holding only the two fixtures
152
+ shell: bash
153
+ run: |
154
+ mkdir -p run
155
+ cp tests/fixtures/hello.html tests/fixtures/hello.css run/
156
+ (cd run && "$VENV_BIN/brevier" render hello.html -o hello.pdf)
157
+
158
+ - name: Check the PDF is real, not just present
159
+ shell: bash
160
+ run: '"$VENV_BIN/python" tests/packaging/check_pdf.py run/hello.pdf'
161
+
162
+ # --- the font-pinned fixture, once per OS, for the content check below ---
163
+
164
+ - name: Fetch the pinned fonts hello-bundled.html embeds
165
+ # tests/fixtures/fonts/ is gitignored (cross-os-proof.yml fetches it
166
+ # the same way) — a checkout alone does not have these files.
167
+ if: matrix.python-version == '3.13'
168
+ shell: bash
169
+ env:
170
+ # Same commit and checksums as cross-os-proof.yml, so both
171
+ # workflows prove against the same font bytes.
172
+ FONTS_COMMIT: 8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5
173
+ SANS_SHA256: bfb7bb691513f12e734dc346c03a03f784912432d7e3fa8e56efcf906fe86b3d
174
+ SERIF_SHA256: 4d8e6761424656867019081a1a01336f3cb086982682698714054fc33f782713
175
+ run: |
176
+ mkdir -p tests/fixtures/fonts
177
+ base="https://raw.githubusercontent.com/google/fonts/$FONTS_COMMIT/ofl"
178
+ curl -sSL -o tests/fixtures/fonts/NotoSans.ttf "$base/notosans/NotoSans%5Bwdth%2Cwght%5D.ttf"
179
+ curl -sSL -o tests/fixtures/fonts/NotoSerif.ttf "$base/notoserif/NotoSerif%5Bwdth%2Cwght%5D.ttf"
180
+ digest() { "$VENV_BIN/python" -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$1"; }
181
+ test "$(digest tests/fixtures/fonts/NotoSans.ttf)" = "$SANS_SHA256" || { echo "NotoSans differs on this runner"; exit 1; }
182
+ test "$(digest tests/fixtures/fonts/NotoSerif.ttf)" = "$SERIF_SHA256" || { echo "NotoSerif differs on this runner"; exit 1; }
183
+
184
+ - name: Render the font-pinned fixture, for the cross-OS content check
185
+ if: matrix.python-version == '3.13'
186
+ shell: bash
187
+ run: |
188
+ mkdir -p run-bundled/fonts
189
+ cp tests/fixtures/hello-bundled.html run-bundled/
190
+ cp tests/fixtures/fonts/*.ttf run-bundled/fonts/
191
+ (cd run-bundled && "$VENV_BIN/brevier" render hello-bundled.html -o hello-bundled.pdf)
192
+
193
+ - uses: actions/upload-artifact@v4
194
+ if: matrix.python-version == '3.13'
195
+ with:
196
+ name: bundled-pdf-${{ runner.os }}
197
+ path: run-bundled/hello-bundled.pdf
198
+
199
+ compare:
200
+ name: compare the font-pinned PDFs across operating systems
201
+ needs: install
202
+ if: always()
203
+ runs-on: ubuntu-latest
204
+ steps:
205
+ - uses: actions/checkout@v4
206
+
207
+ - uses: actions/setup-python@v5
208
+ with:
209
+ python-version: "3.13"
210
+
211
+ - run: python -m pip install --quiet "pypdf>=5,<7"
212
+
213
+ - uses: actions/download-artifact@v4
214
+ with:
215
+ pattern: bundled-pdf-*
216
+ path: pdfs
217
+
218
+ - name: Compare content across operating systems
219
+ shell: bash
220
+ run: python tests/compare_pdf_content.py pdfs/*/hello-bundled.pdf