mirror of
https://github.com/Anon-Planet/thgtoa.git
synced 2026-11-03 18:58:04 +01:00
Compare commits
4
Commits
53d17ca29e
...
69bc8ab079
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69bc8ab079 | ||
|
|
63bb3a6868 | ||
|
|
927f5aac98 | ||
|
|
23bc196e80 |
No files matched your search
@@ -71,8 +71,8 @@ jobs:
|
|||||||
- name: 🖨️ Build PDFs
|
- name: 🖨️ Build PDFs
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
run: python scripts/build_guide_pdf.py --${{ steps.mode.outputs.build_mode }}
|
run: python scripts/build_guide_pdf.py --${{ steps.mode.outputs.build_mode }} 2>/dev/null
|
||||||
|
# ignore stderr (DEPRECATED_ENDPOINT, etc.)
|
||||||
- name: 📤 Upload PDF artifacts
|
- name: 📤 Upload PDF artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ export/thgtoa-dark.pdf.sha256
|
|||||||
export/thgtoa.pdf.b2sum
|
export/thgtoa.pdf.b2sum
|
||||||
export/thgtoa-dark.pdf.b2sum
|
export/thgtoa-dark.pdf.b2sum
|
||||||
export/*.asc
|
export/*.asc
|
||||||
|
export/.*.body.pdf
|
||||||
+12
-2
@@ -32,11 +32,13 @@ schema:
|
|||||||
<!-- Change nothing below this line, please. -->
|
<!-- Change nothing below this line, please. -->
|
||||||
---
|
---
|
||||||
|
|
||||||
## [v1.2.6-pre1]
|
## [v1.2.6]
|
||||||
|
|
||||||
!!! abstract "Meta"
|
!!! abstract "Meta"
|
||||||
|
|
||||||
|
- Released 2026-10-24
|
||||||
- Various website improvements (CSS)
|
- Various website improvements (CSS)
|
||||||
|
- PDF build overhaul: larger text, page numbers, tighter layout, and a new cover
|
||||||
|
|
||||||
!!! info "Changed"
|
!!! info "Changed"
|
||||||
|
|
||||||
@@ -44,12 +46,20 @@ schema:
|
|||||||
- 02-sign.yml: use a fpr match to lock a specific key
|
- 02-sign.yml: use a fpr match to lock a specific key
|
||||||
- 02-sign.yml: quick mkdir -p repo/export before copying files to the dir
|
- 02-sign.yml: quick mkdir -p repo/export before copying files to the dir
|
||||||
- Moved the Acknowledgements section to the bottom
|
- Moved the Acknowledgements section to the bottom
|
||||||
|
- PDF body text enlarged to 13pt, with headings, code, and tables scaled to match
|
||||||
|
- PDF page margins reduced to 12 mm (16 mm at the bottom) and spacing tightened to cut the page count; the cover and Table of Contents use the same margins
|
||||||
|
- PDF tables, code blocks, and admonitions can now break across pages, and table header rows repeat on each page
|
||||||
|
- PDF Table of Contents shrinks slightly (down to 85%) when needed to avoid a nearly empty last page
|
||||||
|
- Glossary abbreviations are no longer underlined in the PDF; the website keeps the underline and tooltips
|
||||||
|
- PDF cover redesigned with Fira Code monospace, green and amber accents, in both light and dark builds
|
||||||
|
|
||||||
!!! tip "Added"
|
!!! tip "Added"
|
||||||
|
|
||||||
- COM Class Identifiers (CLSID/GUID) (NEW)
|
- COM Class Identifiers (CLSID/GUID) (NEW)
|
||||||
- Windows Global Device Identifier (GDID) (NEW)
|
- Windows Global Device Identifier (GDID) (NEW)
|
||||||
- USB bus telemetry (device enumeration history) (NEW)
|
- USB bus telemetry (device enumeration history) (NEW)
|
||||||
|
- Page numbers on every page of both PDFs (bottom right)
|
||||||
|
- Page numbers in the PDF Table of Contents
|
||||||
|
|
||||||
!!! info "Improved"
|
!!! info "Improved"
|
||||||
|
|
||||||
@@ -172,7 +182,7 @@ First automated PDF build and the start of the CI pipeline.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[v1.2.6-pre1]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.6-pre1
|
[v1.2.6]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.6
|
||||||
[v1.2.5]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.5
|
[v1.2.5]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.5
|
||||||
[v1.2.4]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.4
|
[v1.2.4]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.4
|
||||||
[v1.2.3]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.3
|
[v1.2.3]: https://github.com/Anon-Planet/thgtoa/releases/tag/v1.2.3
|
||||||
|
|||||||
+4
-2
@@ -1760,13 +1760,15 @@ There are some forums and subreddits (like r/phoneverification/) where users wil
|
|||||||
|
|
||||||
To this date, we do not know any reputable service that would offer this service and accept cash payments (by post for instance) like some VPN providers. But a few services are providing online phone numbers and do accept Monero which could be reasonably anonymous (yet less recommended than that physical way in the earlier chapter) that you could consider:
|
To this date, we do not know any reputable service that would offer this service and accept cash payments (by post for instance) like some VPN providers. But a few services are providing online phone numbers and do accept Monero which could be reasonably anonymous (yet less recommended than that physical way in the earlier chapter) that you could consider:
|
||||||
|
|
||||||
- **Recommended**: Do not require any identification (even e-mail):
|
- **Recommended**: No name, no e-mail, and no KYC:
|
||||||
|
|
||||||
- (Iceland based, accepts Monero) <https://crypton.sh> <sup>[[Tor Mirror]](http://cryptonx6nsmspsnpicuihgmbbz3qvro4na35od3eht4vojdo7glm6yd.onion)</sup> <sup>[[Archive.org]](https://web.archive.org/web/https://crypton.sh/)</sup>
|
- (Iceland based, accepts Monero) <https://crypton.sh> <sup>[[Tor Mirror]](http://cryptonx6nsmspsnpicuihgmbbz3qvro4na35od3eht4vojdo7glm6yd.onion)</sup> <sup>[[Archive.org]](https://web.archive.org/web/https://crypton.sh/)</sup>
|
||||||
|
|
||||||
- (Ukraine based, accepts Monero) <https://virtualsim.net/> <sup>[[Archive.org]](https://web.archive.org/web/https://virtualsim.net/)</sup>
|
- (Ukraine based, accepts Monero) <https://virtualsim.net/> <sup>[[Archive.org]](https://web.archive.org/web/https://virtualsim.net/)</sup>
|
||||||
|
|
||||||
- Do require identification (valid e-mail):
|
- (180+ countries, accepts crypto) <https://cypheresim.com/esim-with-number> <sup>[[Archive.org]](https://web.archive.org/web/https://cypheresim.com/esim-with-number)</sup>
|
||||||
|
|
||||||
|
- Require valid e-mail:
|
||||||
|
|
||||||
- (US California based, accepts Monero) <https://mobilesms.io> <sup>[[Archive.org]](https://web.archive.org/web/https://mobilesms.io/)</sup>
|
- (US California based, accepts Monero) <https://mobilesms.io> <sup>[[Archive.org]](https://web.archive.org/web/https://mobilesms.io/)</sup>
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@
|
|||||||
background: #ffffff !important;
|
background: #ffffff !important;
|
||||||
color: #1a1a1a !important;
|
color: #1a1a1a !important;
|
||||||
font-family: 'EB Garamond', Georgia, 'Times New Roman', serif !important;
|
font-family: 'EB Garamond', Georgia, 'Times New Roman', serif !important;
|
||||||
font-size: 11pt !important;
|
font-size: 13pt !important;
|
||||||
line-height: 1.65 !important;
|
line-height: 1.45 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
|
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 25mm 28mm 28mm 28mm;
|
margin: 12mm 12mm 16mm 12mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@page :first {
|
@page :first {
|
||||||
margin-top: 30mm;
|
margin-top: 12mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 0 0 12mm !important;
|
padding: 0 !important;
|
||||||
float: none !important;
|
float: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,13 +150,13 @@
|
|||||||
content: none !important;
|
content: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, .md-typeset h1 { font-size: 20pt !important; margin: 18pt 0 6pt !important; }
|
h1, .md-typeset h1 { font-size: 23pt !important; margin: 20pt 0 7pt !important; }
|
||||||
h2, .md-typeset h2 { font-size: 15pt !important; margin: 14pt 0 5pt !important; }
|
h2, .md-typeset h2 { font-size: 18pt !important; margin: 16pt 0 6pt !important; }
|
||||||
h3, .md-typeset h3 { font-size: 12pt !important; margin: 10pt 0 4pt !important; }
|
h3, .md-typeset h3 { font-size: 14.5pt !important; margin: 12pt 0 5pt !important; }
|
||||||
h4, .md-typeset h4 { font-size: 11pt !important; margin: 8pt 0 3pt !important; }
|
h4, .md-typeset h4 { font-size: 13pt !important; margin: 9pt 0 4pt !important; }
|
||||||
h5, h6,
|
h5, h6,
|
||||||
.md-typeset h5,
|
.md-typeset h5,
|
||||||
.md-typeset h6 { font-size: 10pt !important; margin: 6pt 0 2pt !important; }
|
.md-typeset h6 { font-size: 12pt !important; margin: 7pt 0 3pt !important; }
|
||||||
|
|
||||||
p, li, dt, dd, blockquote p,
|
p, li, dt, dd, blockquote p,
|
||||||
.md-typeset p,
|
.md-typeset p,
|
||||||
@@ -165,20 +165,49 @@
|
|||||||
.md-typeset dd {
|
.md-typeset dd {
|
||||||
color: #1a1a1a !important;
|
color: #1a1a1a !important;
|
||||||
font-family: 'EB Garamond', Georgia, serif !important;
|
font-family: 'EB Garamond', Georgia, serif !important;
|
||||||
font-size: 11pt !important;
|
font-size: 13pt !important;
|
||||||
line-height: 1.65 !important;
|
line-height: 1.45 !important;
|
||||||
orphans: 3;
|
orphans: 3;
|
||||||
widows: 3;
|
widows: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-typeset p,
|
||||||
|
.md-typeset ul,
|
||||||
|
.md-typeset ol,
|
||||||
|
.md-typeset dl {
|
||||||
|
margin-top: 0.55em !important;
|
||||||
|
margin-bottom: 0.55em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-typeset li {
|
||||||
|
margin-bottom: 0.15em !important;
|
||||||
|
}
|
||||||
|
|
||||||
a, .md-typeset a {
|
a, .md-typeset a {
|
||||||
color: #00487a !important;
|
color: #00487a !important;
|
||||||
text-decoration: underline !important;
|
text-decoration: underline !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Glossary abbreviations are stripped from the PDF */
|
||||||
|
abbr, abbr[title],
|
||||||
|
.md-typeset abbr,
|
||||||
|
.md-typeset abbr[title] {
|
||||||
|
border-bottom: none !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
cursor: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr::before, abbr::after,
|
||||||
|
abbr[title]::before, abbr[title]::after,
|
||||||
|
.md-typeset abbr::before, .md-typeset abbr::after,
|
||||||
|
.md-typeset abbr[title]::before, .md-typeset abbr[title]::after {
|
||||||
|
content: none !important;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
code, .md-typeset code {
|
code, .md-typeset code {
|
||||||
font-family: 'Fira Code VF', 'Fira Code', 'Courier New', monospace !important;
|
font-family: 'Fira Code VF', 'Fira Code', 'Courier New', monospace !important;
|
||||||
font-size: 9pt !important;
|
font-size: 10.5pt !important;
|
||||||
background: #f4f4f4 !important;
|
background: #f4f4f4 !important;
|
||||||
color: #1a1a1a !important;
|
color: #1a1a1a !important;
|
||||||
border: 1px solid #cccccc !important;
|
border: 1px solid #cccccc !important;
|
||||||
@@ -188,7 +217,7 @@
|
|||||||
|
|
||||||
pre, .md-typeset pre {
|
pre, .md-typeset pre {
|
||||||
font-family: 'Fira Code VF', 'Fira Code', 'Courier New', monospace !important;
|
font-family: 'Fira Code VF', 'Fira Code', 'Courier New', monospace !important;
|
||||||
font-size: 8.5pt !important;
|
font-size: 10pt !important;
|
||||||
background: #f8f8f8 !important;
|
background: #f8f8f8 !important;
|
||||||
color: #1a1a1a !important;
|
color: #1a1a1a !important;
|
||||||
border: 1px solid #cccccc !important;
|
border: 1px solid #cccccc !important;
|
||||||
@@ -196,8 +225,8 @@
|
|||||||
padding: 0.75em 1em !important;
|
padding: 0.75em 1em !important;
|
||||||
white-space: pre-wrap !important;
|
white-space: pre-wrap !important;
|
||||||
word-break: break-all !important;
|
word-break: break-all !important;
|
||||||
page-break-inside: avoid;
|
page-break-inside: auto;
|
||||||
break-inside: avoid;
|
break-inside: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre code, .md-typeset pre code {
|
pre code, .md-typeset pre code {
|
||||||
@@ -210,23 +239,32 @@
|
|||||||
table, .md-typeset table {
|
table, .md-typeset table {
|
||||||
border-collapse: collapse !important;
|
border-collapse: collapse !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
page-break-inside: auto;
|
||||||
|
break-inside: auto;
|
||||||
|
font-size: 11.5pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr, .md-typeset tr {
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
font-size: 10pt !important;
|
}
|
||||||
|
|
||||||
|
thead, .md-typeset thead {
|
||||||
|
display: table-header-group;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, .md-typeset th {
|
th, .md-typeset th {
|
||||||
background: #eeeeee !important;
|
background: #eeeeee !important;
|
||||||
color: #1a1a1a !important;
|
color: #1a1a1a !important;
|
||||||
border: 1px solid #aaaaaa !important;
|
border: 1px solid #aaaaaa !important;
|
||||||
padding: 5pt 8pt !important;
|
padding: 3pt 6pt !important;
|
||||||
font-weight: 700 !important;
|
font-weight: 700 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
td, .md-typeset td {
|
td, .md-typeset td {
|
||||||
color: #1a1a1a !important;
|
color: #1a1a1a !important;
|
||||||
border: 1px solid #cccccc !important;
|
border: 1px solid #cccccc !important;
|
||||||
padding: 4pt 8pt !important;
|
padding: 2.5pt 6pt !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:nth-child(even) td {
|
tr:nth-child(even) td {
|
||||||
@@ -240,8 +278,8 @@
|
|||||||
border-left: 4px solid #555555 !important;
|
border-left: 4px solid #555555 !important;
|
||||||
border: 1px solid #cccccc !important;
|
border: 1px solid #cccccc !important;
|
||||||
color: #1a1a1a !important;
|
color: #1a1a1a !important;
|
||||||
page-break-inside: avoid;
|
page-break-inside: auto;
|
||||||
break-inside: avoid;
|
break-inside: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admonition-title,
|
.admonition-title,
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
ed1fff860d113609cbeaf38beef5342b473d5f09b9263020ef0a82f42ba9c8d4d17ad2743267a76b0f315c4719f9866d3e2018bc8f4eb64a46d0fcbec0c6ee2f thgtoa.pdf
|
5e9375ebd0a30fc9a105957965b8c46c1929db91cae3816b9510d990b0b9d0543c2fb004612e655e4fdafbf1b9fca5a310b7221bdb11f8c0bf66cbf40cd47d88 thgtoa.pdf
|
||||||
1bd77081bf7f2962f8a52a141e286fc7cb571e17b1b551d1baab5fd892cf874a1ace903301f263d7073251a1c53a4d141aa346fb0c87ea0af0b96703127f5133 thgtoa-dark.pdf
|
0a88b526f1be34d0b8b7d581a372b63935aa9fa6b1b7acc342a76e500fbd4cc6b1e03f25101ec0ff89b520dcfe5548511c0188bc367e74b7b1bd8ca59bdf0296 thgtoa-dark.pdf
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
85e8824dcda6176ac4444fdc92ff5272527065aabb2c240d3e2f87418aa255b3 thgtoa.pdf
|
ce3568f3966ed9802ffdf4b83542f46c1941405dd3acb8f94c2dc25064aad75a thgtoa.pdf
|
||||||
28675b585406f0e0543e4f63d5cff6507a064bf4d91db9ff82e4a826e8c8959d thgtoa-dark.pdf
|
e36e5fb1f74bf2f80ba2822d13632d153a9a98666195270c6767e1397defad1c thgtoa-dark.pdf
|
||||||
+55805
-54448
File diff suppressed because it is too large.
Load diff
+27475
-22260
File diff suppressed because it is too large.
Load diff
+232
-47
@@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python scripts/build_guide_pdf.py # Light PDF only (clean light theme)
|
python scripts/build_guide_pdf.py # Light PDF only (clean light theme)
|
||||||
python scripts/build_guide_pdf.py --dark # Dark PDF only (requires light PDF to exist)
|
python scripts/build_guide_pdf.py --dark # Dark PDF only (requires a body-only
|
||||||
|
# cache from a prior light build in
|
||||||
|
# this checkout, e.g. via --both)
|
||||||
python scripts/build_guide_pdf.py --both # Light PDF, then dark PDF
|
python scripts/build_guide_pdf.py --both # Light PDF, then dark PDF
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -17,10 +19,12 @@ import argparse
|
|||||||
import html as _html_mod
|
import html as _html_mod
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -160,6 +164,76 @@ def print_to_pdf(browser: Path, html_file: Path, pdf_out: Path) -> Path:
|
|||||||
return pdf_out
|
return pdf_out
|
||||||
|
|
||||||
|
|
||||||
|
# Page numbers (stamped with qpdf; no extra Python dependencies)
|
||||||
|
|
||||||
|
def stamp_page_numbers(pdf_path: Path) -> None:
|
||||||
|
"""Stamp a right-aligned page number at the bottom of every page, in place.
|
||||||
|
|
||||||
|
Chromium's --print-to-pdf can't draw page numbers when the default
|
||||||
|
header/footer is disabled, and @page margin boxes need Chrome >= 131
|
||||||
|
(CI pins 120). So build a tiny overlay PDF by hand (standard Times font,
|
||||||
|
no dependencies) and merge it with `qpdf --overlay`.
|
||||||
|
|
||||||
|
Called on the body-only PDF, before the cached copy is taken and before
|
||||||
|
the cover + ToC are prepended, so the body starts at page 1 in both the
|
||||||
|
light and dark builds.
|
||||||
|
"""
|
||||||
|
n_pages = int(
|
||||||
|
subprocess.run(
|
||||||
|
["qpdf", "--show-npages", str(pdf_path)],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
width, height = 595.28, 841.89 # A4 in points (matches @page size)
|
||||||
|
font_size = 10
|
||||||
|
baseline_y = 28 # ~10 mm from the bottom edge (inside the 16 mm margin)
|
||||||
|
digit_w = 0.5 * font_size # Times-Roman digits are 500/1000 em
|
||||||
|
right_x = width - 12 * 72 / 25.4 # right text edge (12 mm page margin)
|
||||||
|
|
||||||
|
objs: list[bytes] = [b""] * (3 + 2 * n_pages)
|
||||||
|
kids = " ".join(f"{4 + 2 * i} 0 R" for i in range(n_pages))
|
||||||
|
objs[0] = b"<< /Type /Catalog /Pages 2 0 R >>"
|
||||||
|
objs[1] = f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode()
|
||||||
|
objs[2] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>"
|
||||||
|
for i in range(n_pages):
|
||||||
|
label = str(i + 1)
|
||||||
|
x = right_x - digit_w * len(label)
|
||||||
|
stream = f"BT /F1 {font_size} Tf 0.33 g {x:.2f} {baseline_y} Td ({label}) Tj ET".encode()
|
||||||
|
objs[3 + 2 * i] = (
|
||||||
|
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] "
|
||||||
|
f"/Resources << /Font << /F1 3 0 R >> >> /Contents {5 + 2 * i} 0 R >>"
|
||||||
|
).encode()
|
||||||
|
objs[4 + 2 * i] = (
|
||||||
|
f"<< /Length {len(stream)} >>\nstream\n".encode() + stream + b"\nendstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
out = bytearray(b"%PDF-1.4\n")
|
||||||
|
offsets: list[int] = []
|
||||||
|
for num, body in enumerate(objs, start=1):
|
||||||
|
offsets.append(len(out))
|
||||||
|
out += f"{num} 0 obj\n".encode() + body + b"\nendobj\n"
|
||||||
|
xref_pos = len(out)
|
||||||
|
out += f"xref\n0 {len(objs) + 1}\n".encode()
|
||||||
|
out += b"0000000000 65535 f \n"
|
||||||
|
for off in offsets:
|
||||||
|
out += f"{off:010d} 00000 n \n".encode()
|
||||||
|
out += (
|
||||||
|
f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n"
|
||||||
|
f"startxref\n{xref_pos}\n%%EOF\n"
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
overlay = Path(tmp) / "page-numbers.pdf"
|
||||||
|
overlay.write_bytes(bytes(out))
|
||||||
|
stamped = Path(tmp) / "stamped.pdf"
|
||||||
|
subprocess.run(
|
||||||
|
["qpdf", str(pdf_path), "--overlay", str(overlay), "--", str(stamped)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
shutil.move(str(stamped), str(pdf_path))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Front matter: ToC parsing + HTML generation
|
# Front matter: ToC parsing + HTML generation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -198,7 +272,13 @@ def parse_toc_headings(guide_md: Path) -> list[tuple[int, str]]:
|
|||||||
return headings
|
return headings
|
||||||
|
|
||||||
|
|
||||||
def create_toc_html(tmp_dir: str, headings: list[tuple[int, str]], is_dark: bool) -> str:
|
def create_toc_html(
|
||||||
|
tmp_dir: str,
|
||||||
|
headings: list[tuple[int, str]],
|
||||||
|
pages: list[int | None],
|
||||||
|
is_dark: bool,
|
||||||
|
scale: float = 1.0,
|
||||||
|
) -> str:
|
||||||
"""Write a research-style Table of Contents HTML file and return its path.
|
"""Write a research-style Table of Contents HTML file and return its path.
|
||||||
|
|
||||||
H2 entries: full-width row with dot leader.
|
H2 entries: full-width row with dot leader.
|
||||||
@@ -219,33 +299,31 @@ def create_toc_html(tmp_dir: str, headings: list[tuple[int, str]], is_dark: bool
|
|||||||
h3color = "#444466"
|
h3color = "#444466"
|
||||||
|
|
||||||
rows: list[str] = []
|
rows: list[str] = []
|
||||||
for level, title in headings:
|
for (level, title), page in zip(headings, pages):
|
||||||
safe = _html_mod.escape(title)
|
safe = _html_mod.escape(title)
|
||||||
if level == 2:
|
page_html = f'<span class="toc-page">{page}</span>' if page else ""
|
||||||
rows.append(
|
cls = "toc-h2" if level == 2 else "toc-h3"
|
||||||
f'<div class="toc-h2">'
|
rows.append(
|
||||||
f'<span class="toc-title">{safe}</span>'
|
f'<div class="{cls}">'
|
||||||
f'<span class="toc-dots"></span>'
|
f'<span class="toc-title">{safe}</span>'
|
||||||
f"</div>"
|
f'<span class="toc-dots"></span>'
|
||||||
)
|
f"{page_html}"
|
||||||
else:
|
f"</div>"
|
||||||
rows.append(f'<div class="toc-h3">{safe}</div>')
|
)
|
||||||
|
|
||||||
body = "\n".join(rows)
|
body = "\n".join(rows)
|
||||||
|
|
||||||
css = (
|
css = (
|
||||||
"* { box-sizing: border-box; margin: 0; padding: 0; }\n"
|
"* { box-sizing: border-box; margin: 0; padding: 0; }\n"
|
||||||
"@page { size: A4; margin: 0; }\n"
|
"@page { size: A4; margin: 12mm 12mm 16mm 12mm; }\n"
|
||||||
"html, body {\n"
|
"html, body {\n"
|
||||||
f" width: 210mm;\n"
|
|
||||||
f" background: {bg};\n"
|
f" background: {bg};\n"
|
||||||
f" color: {fg};\n"
|
f" color: {fg};\n"
|
||||||
" font-family: 'EB Garamond', Georgia, 'Times New Roman', serif;\n"
|
" font-family: 'EB Garamond', Georgia, 'Times New Roman', serif;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
".page {\n"
|
".page {\n"
|
||||||
" width: 210mm;\n"
|
" padding: 0;\n"
|
||||||
" min-height: 297mm;\n"
|
f" zoom: {scale};\n"
|
||||||
" padding: 25mm 28mm 28mm 28mm;\n"
|
|
||||||
"}\n"
|
"}\n"
|
||||||
".toc-heading {\n"
|
".toc-heading {\n"
|
||||||
" font-size: 18pt;\n"
|
" font-size: 18pt;\n"
|
||||||
@@ -266,10 +344,14 @@ def create_toc_html(tmp_dir: str, headings: list[tuple[int, str]], is_dark: bool
|
|||||||
" margin-bottom: 1.5pt;\n"
|
" margin-bottom: 1.5pt;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
".toc-title {\n"
|
".toc-title {\n"
|
||||||
" white-space: nowrap;\n"
|
" flex: 0 1 auto;\n"
|
||||||
" overflow: hidden;\n"
|
" max-width: 88%;\n"
|
||||||
|
"}\n"
|
||||||
|
".toc-page {\n"
|
||||||
" flex-shrink: 0;\n"
|
" flex-shrink: 0;\n"
|
||||||
" max-width: 85%;\n"
|
" padding-left: 4pt;\n"
|
||||||
|
" font-style: normal;\n"
|
||||||
|
" font-variant-numeric: tabular-nums;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
".toc-dots {\n"
|
".toc-dots {\n"
|
||||||
" flex: 1;\n"
|
" flex: 1;\n"
|
||||||
@@ -280,6 +362,8 @@ def create_toc_html(tmp_dir: str, headings: list[tuple[int, str]], is_dark: bool
|
|||||||
" min-width: 8pt;\n"
|
" min-width: 8pt;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
".toc-h3 {\n"
|
".toc-h3 {\n"
|
||||||
|
" display: flex;\n"
|
||||||
|
" align-items: baseline;\n"
|
||||||
" font-size: 9pt;\n"
|
" font-size: 9pt;\n"
|
||||||
f" color: {h3color};\n"
|
f" color: {h3color};\n"
|
||||||
" padding-left: 10mm;\n"
|
" padding-left: 10mm;\n"
|
||||||
@@ -304,6 +388,76 @@ def create_toc_html(tmp_dir: str, headings: list[tuple[int, str]], is_dark: bool
|
|||||||
return html_path
|
return html_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Locating headings in the built body PDF (for ToC page numbers)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def locate_heading_pages(
|
||||||
|
pdf_path: Path, headings: list[tuple[int, str]]
|
||||||
|
) -> list[int | None]:
|
||||||
|
"""Return the 1-based page each (level, title) heading starts on in pdf_path.
|
||||||
|
|
||||||
|
Uses pdftotext (poppler, already required by convert.py) and matches each
|
||||||
|
heading, in document order, against a whole line (or up to 3 consecutive
|
||||||
|
wrapped lines), so a passing mention in body text can't be mistaken for
|
||||||
|
the heading. Headings that can't be found get None: the ToC row then shows
|
||||||
|
dots only, and a warning is printed.
|
||||||
|
"""
|
||||||
|
def norm(s: str) -> str:
|
||||||
|
s = unicodedata.normalize("NFKC", s).replace("", "")
|
||||||
|
s = (s.replace("‘", "'").replace("’", "'")
|
||||||
|
.replace("“", '"').replace("”", '"')
|
||||||
|
.replace("–", "-").replace("—", "-"))
|
||||||
|
return " ".join(s.split()).casefold()
|
||||||
|
|
||||||
|
raw = subprocess.run(
|
||||||
|
["pdftotext", "-enc", "UTF-8", str(pdf_path), "-"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
).stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
lines: list[tuple[int, str]] = []
|
||||||
|
for page_no, page_text in enumerate(raw.split("\f"), start=1):
|
||||||
|
for line in page_text.splitlines():
|
||||||
|
n = norm(line)
|
||||||
|
if n:
|
||||||
|
lines.append((page_no, n))
|
||||||
|
|
||||||
|
result: list[int | None] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
for _level, title in headings:
|
||||||
|
target = norm(title)
|
||||||
|
hit: tuple[int, int] | None = None
|
||||||
|
for j in range(cursor, len(lines)):
|
||||||
|
joined = lines[j][1]
|
||||||
|
if not target.startswith(joined):
|
||||||
|
continue
|
||||||
|
for k in range(1, 4):
|
||||||
|
if k > 1:
|
||||||
|
if j + k - 1 >= len(lines):
|
||||||
|
break
|
||||||
|
joined = joined + " " + lines[j + k - 1][1]
|
||||||
|
if not target.startswith(joined):
|
||||||
|
break
|
||||||
|
if joined == target:
|
||||||
|
hit = (j, k)
|
||||||
|
break
|
||||||
|
if hit:
|
||||||
|
break
|
||||||
|
if hit:
|
||||||
|
result.append(lines[hit[0]][0])
|
||||||
|
cursor = hit[0] + hit[1]
|
||||||
|
else:
|
||||||
|
result.append(None)
|
||||||
|
missing.append(title)
|
||||||
|
|
||||||
|
found = len(headings) - len(missing)
|
||||||
|
print(f" Located {found}/{len(headings)} headings in the body PDF.", flush=True)
|
||||||
|
for t in missing:
|
||||||
|
print(f" Warning: no page found for heading: {t!r}", file=sys.stderr)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared Chromium render helper
|
# Shared Chromium render helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -331,7 +485,12 @@ def _render_html_to_pdf(
|
|||||||
# Front matter assembly: cover + ToC prepended to body PDF
|
# Front matter assembly: cover + ToC prepended to body PDF
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def prepend_front_matter(browser: Path, pdf_path: Path, is_dark: bool) -> Path:
|
def prepend_front_matter(
|
||||||
|
browser: Path,
|
||||||
|
pdf_path: Path,
|
||||||
|
is_dark: bool,
|
||||||
|
page_source: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
"""Render cover + ToC and prepend them to pdf_path in-place.
|
"""Render cover + ToC and prepend them to pdf_path in-place.
|
||||||
|
|
||||||
- Cover HTML is sourced from convert.py (single definition).
|
- Cover HTML is sourced from convert.py (single definition).
|
||||||
@@ -350,6 +509,10 @@ def prepend_front_matter(browser: Path, pdf_path: Path, is_dark: bool) -> Path:
|
|||||||
n_h3 = sum(1 for l, _ in headings if l == 3)
|
n_h3 = sum(1 for l, _ in headings if l == 3)
|
||||||
print(f" Found {len(headings)} headings ({n_h2} H2, {n_h3} H3).", flush=True)
|
print(f" Found {len(headings)} headings ({n_h2} H2, {n_h3} H3).", flush=True)
|
||||||
|
|
||||||
|
# Page numbers come from a text-bearing body PDF: pdf_path itself for the
|
||||||
|
# light build; the cached light body for dark (its pages are rasterised).
|
||||||
|
pages = locate_heading_pages(page_source or pdf_path, headings)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
print(" Rendering cover page...", flush=True)
|
print(" Rendering cover page...", flush=True)
|
||||||
cover_html = create_cover_page(tmp, is_dark=is_dark)
|
cover_html = create_cover_page(tmp, is_dark=is_dark)
|
||||||
@@ -357,9 +520,26 @@ def prepend_front_matter(browser: Path, pdf_path: Path, is_dark: bool) -> Path:
|
|||||||
_render_html_to_pdf(browser, cover_html, cover_pdf, ci=ci)
|
_render_html_to_pdf(browser, cover_html, cover_pdf, ci=ci)
|
||||||
|
|
||||||
print(" Rendering ToC page...", flush=True)
|
print(" Rendering ToC page...", flush=True)
|
||||||
toc_html = create_toc_html(tmp, headings, is_dark=is_dark)
|
toc_pdf = os.path.join(tmp, "toc.pdf")
|
||||||
toc_pdf = os.path.join(tmp, "toc.pdf")
|
|
||||||
_render_html_to_pdf(browser, toc_html, toc_pdf, ci=ci)
|
def _render_toc(scale: float) -> int:
|
||||||
|
html_path = create_toc_html(tmp, headings, pages, is_dark=is_dark, scale=scale)
|
||||||
|
_render_html_to_pdf(browser, html_path, toc_pdf, ci=ci)
|
||||||
|
return int(subprocess.run(
|
||||||
|
["qpdf", "--show-npages", toc_pdf],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
).stdout.strip())
|
||||||
|
|
||||||
|
# If the ToC spills onto an extra page, shrink it a little at a time
|
||||||
|
# (down to 85%) until it fits in one page fewer; otherwise keep 100%.
|
||||||
|
toc_pages = _render_toc(1.0)
|
||||||
|
if toc_pages > 1:
|
||||||
|
for scale in (0.97, 0.94, 0.91, 0.88, 0.85):
|
||||||
|
if _render_toc(scale) < toc_pages:
|
||||||
|
print(f" ToC scaled to {scale:.0%} to save a page.", flush=True)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
_render_toc(1.0)
|
||||||
|
|
||||||
merged = str(pdf_path.parent / f".{pdf_path.name}.with-front")
|
merged = str(pdf_path.parent / f".{pdf_path.name}.with-front")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -373,19 +553,15 @@ def prepend_front_matter(browser: Path, pdf_path: Path, is_dark: bool) -> Path:
|
|||||||
return pdf_path
|
return pdf_path
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def build_dark_pdf(browser: Path, body_pdf: Path, dark_pdf: Path) -> Path:
|
||||||
# Dark PDF build (delegates to convert.py which adds its own front matter)
|
"""Convert a body-only PDF to dark mode, then prepend fresh front matter."""
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_dark_pdf(light_pdf: Path, dark_pdf: Path) -> Path:
|
|
||||||
"""Convert the light PDF to dark mode using scripts/convert.py."""
|
|
||||||
convert_script = repo_root() / "scripts" / "convert.py"
|
convert_script = repo_root() / "scripts" / "convert.py"
|
||||||
print(f"Converting {light_pdf.name} -> {dark_pdf.name} (dark mode)...")
|
print(f"Converting {body_pdf.name} -> {dark_pdf.name} (dark mode)...")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[sys.executable, str(convert_script), str(light_pdf), str(dark_pdf)],
|
[sys.executable, str(convert_script), str(body_pdf), str(dark_pdf)],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
return dark_pdf
|
return prepend_front_matter(browser, dark_pdf, is_dark=True, page_source=body_pdf)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -423,13 +599,23 @@ def main() -> int:
|
|||||||
|
|
||||||
mode = ap.add_mutually_exclusive_group()
|
mode = ap.add_mutually_exclusive_group()
|
||||||
mode.add_argument("--dark", action="store_true",
|
mode.add_argument("--dark", action="store_true",
|
||||||
help="Dark PDF only (light PDF must already exist)")
|
help="Dark PDF only (requires a body-only cache from a prior light build)")
|
||||||
mode.add_argument("--both", action="store_true",
|
mode.add_argument("--both", action="store_true",
|
||||||
help="Build light PDF, then dark PDF")
|
help="Build light PDF, then dark PDF")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
build_light = not args.dark
|
build_light = not args.dark
|
||||||
build_dark = args.dark or args.both
|
build_dark = args.dark or args.both
|
||||||
|
body_cache = args.pdf_light.parent / f".{args.pdf_light.stem}.body.pdf"
|
||||||
|
|
||||||
|
browser = find_chromium_executable()
|
||||||
|
if not browser:
|
||||||
|
print(
|
||||||
|
"No Chromium-based browser found (Chrome, Edge, or Chromium). "
|
||||||
|
"Install Google Chrome or Microsoft Edge, or add Chromium to PATH.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
# --- Light PDF ---
|
# --- Light PDF ---
|
||||||
if build_light:
|
if build_light:
|
||||||
@@ -442,31 +628,30 @@ def main() -> int:
|
|||||||
print(f"Missing {guide_html}; run without --skip-mkdocs first.", file=sys.stderr)
|
print(f"Missing {guide_html}; run without --skip-mkdocs first.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
browser = find_chromium_executable()
|
|
||||||
if not browser:
|
|
||||||
print(
|
|
||||||
"No Chromium-based browser found (Chrome, Edge, or Chromium). "
|
|
||||||
"Install Google Chrome or Microsoft Edge, or add Chromium to PATH.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
out_light = print_to_pdf(browser, guide_html, args.pdf_light)
|
out_light = print_to_pdf(browser, guide_html, args.pdf_light)
|
||||||
|
print(" Stamping page numbers...", flush=True)
|
||||||
|
stamp_page_numbers(out_light)
|
||||||
|
|
||||||
|
import shutil as _shutil # noqa: PLC0415
|
||||||
|
body_cache.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_shutil.copy2(out_light, body_cache)
|
||||||
|
|
||||||
out_light = prepend_front_matter(browser, out_light, is_dark=False)
|
out_light = prepend_front_matter(browser, out_light, is_dark=False)
|
||||||
size_kb = out_light.stat().st_size // 1024
|
size_kb = out_light.stat().st_size // 1024
|
||||||
print(f"Wrote {out_light.resolve()} ({size_kb} KiB) [Light Mode]")
|
print(f"Wrote {out_light.resolve()} ({size_kb} KiB) [Light Mode]")
|
||||||
|
|
||||||
# --- Dark PDF ---
|
# --- Dark PDF ---
|
||||||
if build_dark:
|
if build_dark:
|
||||||
if not args.pdf_light.exists():
|
if not body_cache.exists():
|
||||||
print(
|
print(
|
||||||
f"Light PDF not found at {args.pdf_light}. "
|
f"No body-only PDF cached at {body_cache}. "
|
||||||
"Run without --dark first, or use --both.",
|
"Run with --both (or a plain light build first) so a fresh "
|
||||||
|
"body-only copy exists before converting to dark mode.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
out_dark = build_dark_pdf(args.pdf_light, args.pdf_dark)
|
out_dark = build_dark_pdf(browser, body_cache, args.pdf_dark)
|
||||||
size_kb = out_dark.stat().st_size // 1024
|
size_kb = out_dark.stat().st_size // 1024
|
||||||
print(f"Wrote {out_dark.resolve()} ({size_kb} KiB) [Dark Mode]")
|
print(f"Wrote {out_dark.resolve()} ({size_kb} KiB) [Dark Mode]")
|
||||||
|
|
||||||
|
|||||||
+85
-95
@@ -58,9 +58,9 @@ def apply_dark_theme(
|
|||||||
Remap a white-background page image to a dark theme.
|
Remap a white-background page image to a dark theme.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
- Near-white pixels (page background) → bg color
|
- Near-white pixels (page background) → bg color
|
||||||
- Dark, low-saturation pixels (text) → text color
|
- Dark, low-saturation pixels (text) → text color
|
||||||
- Blue-dominant dark pixels (links) → link color
|
- Blue-dominant dark pixels (links) → link color
|
||||||
- High-saturation pixels (photos/images) → preserved exactly as-is
|
- High-saturation pixels (photos/images) → preserved exactly as-is
|
||||||
(no dark remapping; images stay at natural colors)
|
(no dark remapping; images stay at natural colors)
|
||||||
|
|
||||||
@@ -181,17 +181,36 @@ def _check_dependencies() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def create_cover_page(tmp_dir: str, is_dark: bool) -> str:
|
def create_cover_page(tmp_dir: str, is_dark: bool) -> str:
|
||||||
"""Create a research-style text-only cover page for Chromium rendering."""
|
"""Create the hacker-theme text cover page for Chromium rendering."""
|
||||||
|
# Hacker theme: monospace type with green/amber accents, drawn directly on
|
||||||
|
# the page so the cover matches the body pages in both builds.
|
||||||
if is_dark:
|
if is_dark:
|
||||||
bg_color = '#1f1f31'
|
bg_color = '#1f1f31'
|
||||||
text_color = '#e0e0e0'
|
green = '#33ff66'
|
||||||
rule_color = '#4a4a6a'
|
amber = '#ffb000'
|
||||||
meta_color = '#a0a0c0'
|
muted = '#a8b0c0'
|
||||||
|
glow = '0 0 6px rgba(51, 255, 102, 0.45)'
|
||||||
else:
|
else:
|
||||||
bg_color = '#ffffff'
|
bg_color = '#ffffff'
|
||||||
text_color = '#1a1a1a'
|
green = '#005c2e'
|
||||||
rule_color = '#cccccc'
|
amber = '#8a5a00'
|
||||||
meta_color = '#555555'
|
muted = '#555555'
|
||||||
|
glow = 'none'
|
||||||
|
|
||||||
|
# Embed Fira Code so the cover matches the site regardless of what the
|
||||||
|
# machine running Chromium has installed.
|
||||||
|
import base64
|
||||||
|
font_path = (Path(__file__).resolve().parent.parent / 'docs' / 'fonts'
|
||||||
|
/ 'fira-code' / 'fira-code-latin-wght-normal.woff2')
|
||||||
|
font_face = ''
|
||||||
|
if font_path.is_file():
|
||||||
|
b64 = base64.b64encode(font_path.read_bytes()).decode('ascii')
|
||||||
|
font_face = (
|
||||||
|
"@font-face { font-family: 'Fira Code Cover'; "
|
||||||
|
f"src: url(data:font/woff2;base64,{b64}) format('woff2'); "
|
||||||
|
"font-weight: 300 700; font-style: normal; }"
|
||||||
|
)
|
||||||
|
mono = "'Fira Code Cover', 'Fira Code', 'DejaVu Sans Mono', Consolas, 'Courier New', monospace"
|
||||||
|
|
||||||
html_path = os.path.join(tmp_dir, 'cover.html')
|
html_path = os.path.join(tmp_dir, 'cover.html')
|
||||||
with open(html_path, 'w', encoding='utf-8') as f:
|
with open(html_path, 'w', encoding='utf-8') as f:
|
||||||
@@ -200,6 +219,7 @@ def create_cover_page(tmp_dir: str, is_dark: bool) -> str:
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<style>
|
<style>
|
||||||
|
{font_face}
|
||||||
* {{
|
* {{
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -213,8 +233,9 @@ html, body {{
|
|||||||
width: 210mm;
|
width: 210mm;
|
||||||
height: 297mm;
|
height: 297mm;
|
||||||
background: {bg_color};
|
background: {bg_color};
|
||||||
color: {text_color};
|
font-family: {mono};
|
||||||
font-family: 'EB Garamond', Georgia, 'Times New Roman', serif;
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
}}
|
}}
|
||||||
.page {{
|
.page {{
|
||||||
width: 210mm;
|
width: 210mm;
|
||||||
@@ -223,41 +244,51 @@ html, body {{
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 30mm 25mm;
|
padding: 12mm 12mm 16mm 12mm;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}}
|
}}
|
||||||
.title {{
|
.title {{
|
||||||
font-size: 28pt;
|
font-size: 26pt;
|
||||||
font-weight: normal;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: -0.01em;
|
||||||
margin-bottom: 10mm;
|
color: {green};
|
||||||
|
text-shadow: {glow};
|
||||||
|
margin-bottom: 12mm;
|
||||||
}}
|
}}
|
||||||
.rule {{
|
.cursor {{
|
||||||
width: 80mm;
|
display: inline-block;
|
||||||
height: 1px;
|
width: 0.55em;
|
||||||
background: {rule_color};
|
height: 0.95em;
|
||||||
margin: 0 auto 10mm auto;
|
margin-left: 0.15em;
|
||||||
|
background: {green};
|
||||||
|
vertical-align: -0.1em;
|
||||||
}}
|
}}
|
||||||
.subtitle {{
|
.subtitle {{
|
||||||
font-size: 13pt;
|
font-size: 12pt;
|
||||||
font-weight: normal;
|
line-height: 1.6;
|
||||||
font-style: italic;
|
color: {muted};
|
||||||
color: {meta_color};
|
|
||||||
margin-bottom: 14mm;
|
margin-bottom: 14mm;
|
||||||
}}
|
}}
|
||||||
.meta {{
|
.subtitle::before {{
|
||||||
font-size: 11pt;
|
content: "// ";
|
||||||
color: {meta_color};
|
color: {amber};
|
||||||
line-height: 1.9;
|
|
||||||
}}
|
}}
|
||||||
.meta strong {{
|
.info {{
|
||||||
color: {text_color};
|
align-self: flex-end;
|
||||||
font-weight: normal;
|
text-align: right;
|
||||||
|
margin-right: 13mm;
|
||||||
|
position: relative;
|
||||||
|
top: 10mm;
|
||||||
|
}}
|
||||||
|
.meta {{
|
||||||
|
font-size: 11.5pt;
|
||||||
|
line-height: 1.9;
|
||||||
|
color: {green};
|
||||||
}}
|
}}
|
||||||
.version {{
|
.version {{
|
||||||
font-size: 11pt;
|
font-size: 11.5pt;
|
||||||
color: {meta_color};
|
color: {amber};
|
||||||
margin-top: 12mm;
|
margin-top: 12mm;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}}
|
}}
|
||||||
@@ -265,15 +296,16 @@ html, body {{
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p class="title">The Hitchhiker’s Guide<br>to Online Anonymity</p>
|
<h1 class="title">The Hitchhiker's Guide<br>to Online Anonymity</h1>
|
||||||
<div class="rule"></div>
|
|
||||||
<p class="subtitle">The comprehensive guide for online anonymity and OpSec.</p>
|
<p class="subtitle">The comprehensive guide for online anonymity and OpSec.</p>
|
||||||
<div class="meta">
|
<div class="info">
|
||||||
<p><strong>Author</strong> Anonymous Planet</p>
|
<div class="meta">
|
||||||
<p><strong>License</strong> Creative Commons BY-SA 4.0</p>
|
<p>Anonymous Planet</p>
|
||||||
<p><strong>Source</strong> https://anonymousplanet.net</p>
|
<p>Creative Commons BY-SA 4.0</p>
|
||||||
|
<p>https://anonymousplanet.net</p>
|
||||||
|
</div>
|
||||||
|
<p class="version">v1.2.6 — October 24 2026</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="version">v1.2.6 — July 12 2026</p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>""")
|
</html>""")
|
||||||
@@ -296,7 +328,9 @@ def convert_pdf_to_dark(
|
|||||||
avoid OOM, then merged with qpdf. Falls back to single-pass Pillow save
|
avoid OOM, then merged with qpdf. Falls back to single-pass Pillow save
|
||||||
if qpdf is not available (fine for small documents).
|
if qpdf is not available (fine for small documents).
|
||||||
|
|
||||||
Adds a cover page at the start with title/subtitle/version info.
|
Does not add a cover page or ToC. Pass a body-only PDF and prepend
|
||||||
|
front matter separately (build_guide_pdf.py does this for both the
|
||||||
|
light and dark builds so it's generated exactly once per PDF).
|
||||||
"""
|
"""
|
||||||
input_path = str(input_path)
|
input_path = str(input_path)
|
||||||
output_path = str(output_path)
|
output_path = str(output_path)
|
||||||
@@ -329,50 +363,15 @@ def convert_pdf_to_dark(
|
|||||||
if out_dir:
|
if out_dir:
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
# Determine theme from filename for cover page
|
# 2. Determine theme from filename for downstream use by callers
|
||||||
|
# (front matter (cover + ToC) is prepended by build_guide_pdf.py,
|
||||||
|
# not here, so it's generated exactly once regardless of light/dark).
|
||||||
pdf_output = Path(output_path)
|
pdf_output = Path(output_path)
|
||||||
pdf_name = pdf_output.stem.lower()
|
pdf_name = pdf_output.stem.lower()
|
||||||
is_dark = 'dark' in pdf_name
|
is_dark = 'dark' in pdf_name
|
||||||
|
|
||||||
print(f" Theme: {'Going dark' if is_dark else 'Light mode'}")
|
print(f" Theme: {'Going dark' if is_dark else 'Light mode'}")
|
||||||
|
|
||||||
# 2. Build front matter: cover + ToC
|
|
||||||
# Import ToC helpers from build_guide_pdf.py (single source of truth).
|
|
||||||
scripts_dir = str(Path(__file__).resolve().parent)
|
|
||||||
if scripts_dir not in sys.path:
|
|
||||||
sys.path.insert(0, scripts_dir)
|
|
||||||
from build_guide_pdf import parse_toc_headings, create_toc_html # noqa: PLC0415
|
|
||||||
|
|
||||||
cover_html_path = create_cover_page(tmp, is_dark)
|
|
||||||
browser = find_chromium_executable()
|
|
||||||
|
|
||||||
if not browser:
|
|
||||||
raise RuntimeError(
|
|
||||||
"No Chromium-based browser found; needed for cover page rendering.\n"
|
|
||||||
"Install Chrome, Edge, or add Chromium to PATH."
|
|
||||||
)
|
|
||||||
|
|
||||||
cover_pdf = os.path.join(tmp, 'cover.pdf')
|
|
||||||
cmd = [str(browser), "--headless=new", "--disable-gpu",
|
|
||||||
"--no-pdf-header-footer", "--print-background", "--no-margins",
|
|
||||||
f"--print-to-pdf={cover_pdf}", cover_html_path]
|
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
|
||||||
|
|
||||||
guide_md = Path(__file__).resolve().parent.parent / 'docs' / 'guide' / 'index.md'
|
|
||||||
if guide_md.is_file():
|
|
||||||
print(" Building ToC...", flush=True)
|
|
||||||
headings = parse_toc_headings(guide_md)
|
|
||||||
toc_html_path = create_toc_html(tmp, headings, is_dark)
|
|
||||||
toc_pdf = os.path.join(tmp, 'toc.pdf')
|
|
||||||
cmd_toc = [str(browser), "--headless=new", "--disable-gpu",
|
|
||||||
"--no-pdf-header-footer", "--print-background", "--no-margins",
|
|
||||||
f"--print-to-pdf={toc_pdf}", toc_html_path]
|
|
||||||
subprocess.run(cmd_toc, check=True, capture_output=True)
|
|
||||||
front_pages = [cover_pdf, toc_pdf]
|
|
||||||
else:
|
|
||||||
print(" Warning: guide/index.md not found, skipping ToC.", file=sys.stderr)
|
|
||||||
front_pages = [cover_pdf]
|
|
||||||
|
|
||||||
# 3. Process pages with theme remapping
|
# 3. Process pages with theme remapping
|
||||||
use_batches = total > batch_size and _check_qpdf()
|
use_batches = total > batch_size and _check_qpdf()
|
||||||
|
|
||||||
@@ -396,10 +395,10 @@ def convert_pdf_to_dark(
|
|||||||
_save_images_as_pdf(dark, batch_path)
|
_save_images_as_pdf(dark, batch_path)
|
||||||
batch_files.append(batch_path)
|
batch_files.append(batch_path)
|
||||||
|
|
||||||
# Merge batches with front matter using qpdf
|
# Merge batches into the final output using qpdf
|
||||||
print(" Merging batches and front matter...", flush=True)
|
print(" Merging batches...", flush=True)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
['qpdf', '--empty', '--pages'] + front_pages + batch_files + ['--', output_path],
|
['qpdf', '--empty', '--pages'] + batch_files + ['--', output_path],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -413,15 +412,6 @@ def convert_pdf_to_dark(
|
|||||||
|
|
||||||
_save_images_as_pdf(dark_pages, output_path)
|
_save_images_as_pdf(dark_pages, output_path)
|
||||||
|
|
||||||
# Prepend front matter to the single-pass output
|
|
||||||
if not use_batches:
|
|
||||||
tmp_body = os.path.join(tmp, 'body_only.pdf')
|
|
||||||
os.rename(output_path, tmp_body)
|
|
||||||
subprocess.run(
|
|
||||||
['qpdf', '--empty', '--pages'] + front_pages + [tmp_body] + ['--', output_path],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
size_mb = os.path.getsize(output_path) / 1024 / 1024
|
size_mb = os.path.getsize(output_path) / 1024 / 1024
|
||||||
print(f" Saved → {output_path} ({size_mb:.1f} MB)")
|
print(f" Saved → {output_path} ({size_mb:.1f} MB)")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user