book-kit 0.0.0-stage → 0.6.0

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +72 -0
  3. package/README.md +251 -2
  4. package/bin/book-kit.js +115 -0
  5. package/commands/_emoji_io.py +32 -0
  6. package/commands/audit-chapter-lengths.js +244 -0
  7. package/commands/check-epub-size.js +68 -0
  8. package/commands/check-pdf-page-count.js +162 -0
  9. package/commands/composite-kindle-emoji.py +85 -0
  10. package/commands/composite-print-emoji.py +98 -0
  11. package/commands/conventions.js +76 -0
  12. package/commands/convert-pdfx.sh +194 -0
  13. package/commands/download-icc-profile.sh +105 -0
  14. package/commands/fonts.py +212 -0
  15. package/commands/generate-reference-docx.py +344 -0
  16. package/commands/kindle-fonts.js +173 -0
  17. package/commands/links-to-footnotes.js +366 -0
  18. package/commands/markdownlint.js +25 -0
  19. package/commands/minify-css.js +43 -0
  20. package/commands/optimize-images.js +73 -0
  21. package/commands/parity.sh +150 -0
  22. package/commands/postprocess-epub.js +541 -0
  23. package/commands/prep-print-src.js +77 -0
  24. package/commands/prose.js +38 -0
  25. package/commands/release-files.js +89 -0
  26. package/commands/reorder-print-frontmatter.js +79 -0
  27. package/commands/strip-page-rules.js +1168 -0
  28. package/commands/strip-property-rules.js +93 -0
  29. package/commands/update-key-takeaways.js +314 -0
  30. package/commands/validate.js +283 -0
  31. package/configs/markdownlint-cli2.jsonc +98 -0
  32. package/configs/textlintrc.json +22 -0
  33. package/css/theme/01-tokens.css +83 -0
  34. package/css/theme/02-fonts.css +79 -0
  35. package/css/theme/03-prose.css +53 -0
  36. package/css/theme/04-base.css +226 -0
  37. package/css/theme/05-components.css +546 -0
  38. package/css/theme/06-utilities.css +40 -0
  39. package/css/theme/07-ereader.css +245 -0
  40. package/css/theme/08-epub.css +865 -0
  41. package/css/theme/09-back-matter.css +47 -0
  42. package/css/theme/10-dark.css +302 -0
  43. package/css/theme/11-print.css +1229 -0
  44. package/css/theme.css +38 -0
  45. package/defaults/book.config.yml +318 -0
  46. package/defaults/pandoc-base.yml +19 -0
  47. package/defaults/pandoc-docx.yml +6 -0
  48. package/defaults/pandoc-epub-fonts.yml +6 -0
  49. package/defaults/pandoc-epub.yml +15 -0
  50. package/defaults/pandoc-kindle.yml +6 -0
  51. package/emoji/1f382.png +0 -0
  52. package/emoji/1f389.png +0 -0
  53. package/emoji/1f440.png +0 -0
  54. package/emoji/1f44d.png +0 -0
  55. package/emoji/1f44f.png +0 -0
  56. package/emoji/1f454.png +0 -0
  57. package/emoji/1f4a1.png +0 -0
  58. package/emoji/1f4bb.png +0 -0
  59. package/emoji/1f602.png +0 -0
  60. package/emoji/1f603.png +0 -0
  61. package/emoji/1f604.png +0 -0
  62. package/emoji/1f60a.png +0 -0
  63. package/emoji/1f60f.png +0 -0
  64. package/emoji/1f64c.png +0 -0
  65. package/emoji/1f64f.png +0 -0
  66. package/emoji/1f914.png +0 -0
  67. package/emoji/1f923.png +0 -0
  68. package/emoji/2714.png +0 -0
  69. package/emoji/274c.png +0 -0
  70. package/filters/add-div-titles.lua +196 -0
  71. package/filters/arc-notice.lua +36 -0
  72. package/filters/body-emoji-images.lua +48 -0
  73. package/filters/callout-classes.lua +40 -0
  74. package/filters/lib/book_kit.lua +110 -0
  75. package/filters/revision.lua +17 -0
  76. package/filters/strip-comments.lua +40 -0
  77. package/filters/strip-emoji-kindle.lua +134 -0
  78. package/fonts/LICENSE-source-code-pro.md +93 -0
  79. package/fonts/LICENSE-source-sans.md +93 -0
  80. package/fonts/LICENSE-source-serif.md +93 -0
  81. package/fonts/recipe.json +41 -0
  82. package/just/book-kit.just +229 -0
  83. package/lib/acronyms.js +47 -0
  84. package/lib/callouts.js +17 -0
  85. package/lib/config.js +211 -0
  86. package/lib/is-main.js +21 -0
  87. package/lib/lint.js +148 -0
  88. package/lib/pandoc.js +148 -0
  89. package/lib/paragraphs.js +173 -0
  90. package/lib/revision.js +35 -0
  91. package/lib/structure.js +39 -0
  92. package/lib/textlint-github-formatter.js +58 -0
  93. package/lib/validate.js +202 -0
  94. package/package.json +98 -4
  95. package/presets/business-nonfiction/CONVENTIONS.md +35 -0
  96. package/presets/business-nonfiction/preset.yml +128 -0
  97. package/profiles/pdfx-def.ps +42 -0
  98. package/requirements.txt +14 -0
  99. package/rules/no-ai-like-patterns.js +209 -0
  100. package/rules/no-assumptive.js +76 -0
  101. package/rules/no-bare-acronyms.js +129 -0
  102. package/rules/no-cliches.js +544 -0
  103. package/rules/no-cultural-idioms.js +422 -0
  104. package/rules/no-vague-intensifiers.js +94 -0
  105. package/templates/template-epub.html +79 -0
  106. package/templates/template.html +73 -0
  107. package/toolchain.json +42 -0
  108. package/validators/acronym-expansion.js +94 -0
  109. package/validators/bold-keywords.js +56 -0
  110. package/validators/callout-balance.js +74 -0
  111. package/validators/callout-divs.js +66 -0
  112. package/validators/callout-labels.js +102 -0
  113. package/validators/chapter-lengths.js +14 -0
  114. package/validators/chapter.js +57 -0
  115. package/validators/cross-references.js +45 -0
  116. package/validators/doubled-words.js +76 -0
  117. package/validators/duplicate-headings.js +52 -0
  118. package/validators/em-dashes.js +73 -0
  119. package/validators/escaped-smallcaps.js +46 -0
  120. package/validators/footnotes.js +78 -0
  121. package/validators/heading-hierarchy.js +58 -0
  122. package/validators/hypothetical-hooks.js +103 -0
  123. package/validators/index-integrity.js +28 -0
  124. package/validators/index.js +73 -0
  125. package/validators/list-punctuation.js +123 -0
  126. package/validators/min-opener-length.js +87 -0
  127. package/validators/no-bare-url-link-text.js +38 -0
  128. package/validators/no-todo-markers.js +49 -0
  129. package/validators/no-transition-todos.js +34 -0
  130. package/validators/number-range-dashes.js +72 -0
  131. package/validators/paragraph-length.js +62 -0
  132. package/validators/quote-punctuation.js +85 -0
  133. package/validators/section-intro-final-lead.js +90 -0
  134. package/validators/section-intro-has-lead.js +41 -0
  135. package/validators/section-intro-no-tldr.js +37 -0
  136. package/validators/section-intros.js +33 -0
  137. package/validators/sentence-starters.js +132 -0
  138. package/validators/smallcaps.js +67 -0
  139. package/validators/tldr-length.js +58 -0
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ // book-kit conventions
4
+ //
5
+ // Print the book's writing conventions as Markdown, from its resolved config:
6
+ // the callouts and how to write them, the structure the validators expect,
7
+ // which checks run, and each preset's CONVENTIONS.md. The Claude agents in
8
+ // claude-plugin/ read this instead of hardcoding one house style.
9
+
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { loadConfig } from '../lib/config.js';
13
+ import { enabledRules } from '../lib/lint.js';
14
+ import { BUILTIN_VALIDATORS } from '../validators/index.js';
15
+
16
+ const config = loadConfig();
17
+ const { callouts = [], structure, validators, paths } = config;
18
+ const code = (s) => `\`${s}\``;
19
+ const lines = [];
20
+ const say = (...more) => lines.push(...more);
21
+
22
+ say(
23
+ `# Writing conventions: ${config.metadata.title}`,
24
+ '',
25
+ `Generated by ${code('book-kit conventions')} from book.config.yml${config.presets.length ? ` and ${config.presets.map((p) => code(p.name)).join(', ')}` : ''}. The book's CLAUDE.md and style guide win where they differ.`,
26
+ '',
27
+ '## Structure',
28
+ '',
29
+ `- Chapters are Markdown files in ${code(`${paths.src}/`)}, built in the order ${code(paths.index)} lists them. Add a new chapter to ${code(paths.index)} in the same change.`,
30
+ '- Each chapter opens with an H2 title. Use H3 and H4 below it, and never skip a level.',
31
+ );
32
+ if (structure.required_callout) {
33
+ say(`- Every chapter opens with a ${code(`.${structure.required_callout}`)} callout, right after the title.`);
34
+ }
35
+ if (callouts.some((c) => c.class === 'takeaways')) {
36
+ say(`- Files ending in ${code(`${structure.takeaways_suffix}.md`)} are generated from the TL;DRs by ${code('just key-takeaways')}; never edit them by hand.`);
37
+ }
38
+ say(
39
+ `- Not narrative chapters (skipped by the prose checks): ${structure.structural_slugs.map(code).join(', ')}, and drafts ending in ${code(`${structure.draft_suffix}.md`)}.`,
40
+ '',
41
+ '## Callouts',
42
+ '',
43
+ callouts.length
44
+ ? 'Write a callout as a fenced div whose class and custom-style match. The build adds the label, so never type it; a typed one renders twice.'
45
+ : 'This book defines no callouts.',
46
+ '',
47
+ );
48
+ if (callouts.length) {
49
+ const first = callouts[0].class;
50
+ say('```markdown', `::: {.${first} custom-style="${first}"}`, 'Text.', ':::', '```', '');
51
+ for (const c of callouts) {
52
+ const label = c.label ? ` (label "${c.label}")` : '';
53
+ const pair = c.pair_with ? `; a chapter with one should also have ${code(`.${c.pair_with}`)}` : '';
54
+ say(`- ${code(`.${c.class}`)}${label}${c.aria ? `: ${c.aria}` : ''}${pair}`);
55
+ }
56
+ say('');
57
+ }
58
+
59
+ const on = BUILTIN_VALIDATORS.map((v) => v.name).filter((name) => validators.severity?.[name] !== 'off');
60
+ const rules = enabledRules(config).map((r) => r.name);
61
+ say(
62
+ '## Checks',
63
+ '',
64
+ `Run ${code('just check-all')} before handing work back: ${code('book-kit validate')}, markdownlint, and textlint.`,
65
+ '',
66
+ `- Validators that run: ${on.map(code).join(', ')}.${(validators.plugins ?? []).length ? ` Plus the book's own: ${validators.plugins.map(code).join(', ')}.` : ''}`,
67
+ `- The kit's prose rules that run: ${rules.length ? rules.map(code).join(', ') : 'none'}.`,
68
+ '',
69
+ );
70
+
71
+ for (const { name, dir } of config.presets) {
72
+ const file = join(dir, 'CONVENTIONS.md');
73
+ if (existsSync(file)) say(`<!-- from preset ${name} -->`, '', readFileSync(file, 'utf8').trim(), '');
74
+ }
75
+
76
+ process.stdout.write(`${lines.join('\n').trimEnd()}\n`);
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Converts a PDF to PDF/X-1a:2001 (CMYK, no transparency, embedded fonts)
5
+ # for print distribution via IngramSpark or similar POD services.
6
+ #
7
+ # Usage:
8
+ # book-kit convert-pdfx [input.pdf output.pdf [title]]
9
+ #
10
+ # With no arguments, converts the book's print PDF (dist/<basename>.pdf) to
11
+ # dist/<basename><output.pdfx_suffix>.pdf, titled print.pdfx_title: what
12
+ # `just pdfx` does, without rebuilding the PDF (e.g. in a CI job that
13
+ # downloaded it from another).
14
+ #
15
+ # Requirements:
16
+ # - Ghostscript (gs) installed
17
+ # - USWebCoatedSWOP.icc: $BOOK_KIT_ICC_PROFILE if set, else the book's
18
+ # profiles/USWebCoatedSWOP.icc if present, else downloaded (sha256-pinned)
19
+ # into the user cache by download-icc-profile.sh
20
+ #
21
+ # Runs from the book root (book-kit sets the working directory and
22
+ # BOOK_KIT_ROOT).
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ KIT_ROOT="${BOOK_KIT_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
26
+
27
+ if [ $# -eq 0 ]; then
28
+ bk() { node "$SCRIPT_DIR/../bin/book-kit.js" config "$1"; }
29
+ dist="$(bk paths.dist)"
30
+ name="$(bk output.basename)"
31
+ set -- "$dist/$name.pdf" "$dist/$name$(bk output.pdfx_suffix).pdf" "$(bk print.pdfx_title)"
32
+ elif [ $# -lt 2 ]; then
33
+ echo "Usage: book-kit convert-pdfx [input.pdf output.pdf [title]]" >&2
34
+ exit 2
35
+ fi
36
+ INPUT="$1"
37
+ OUTPUT="$2"
38
+ TITLE="${3:-}"
39
+
40
+ ICC_PROFILE="$(bash "$SCRIPT_DIR/download-icc-profile.sh" --print-path)"
41
+ PDFX_DEF="$KIT_ROOT/profiles/pdfx-def.ps"
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Preflight checks
45
+ # ---------------------------------------------------------------------------
46
+
47
+ # Check for Ghostscript
48
+ if ! command -v gs &>/dev/null; then
49
+ echo "✗ Error: Ghostscript (gs) is not installed." >&2
50
+ echo "" >&2
51
+ echo " Install it with:" >&2
52
+ echo " macOS: brew install ghostscript" >&2
53
+ echo " Ubuntu: sudo apt-get install ghostscript" >&2
54
+ echo " Fedora: sudo dnf install ghostscript" >&2
55
+ exit 1
56
+ fi
57
+
58
+ # Fetch (or locate) the ICC profile; download-icc-profile.sh verifies its sha256.
59
+ bash "$SCRIPT_DIR/download-icc-profile.sh" >/dev/null
60
+
61
+ # Check for input PDF
62
+ if [ ! -f "$INPUT" ]; then
63
+ echo "✗ Error: Input PDF not found: $INPUT" >&2
64
+ echo " Build it first with: just pdf" >&2
65
+ exit 1
66
+ fi
67
+
68
+ # Check for PDF/X definition file
69
+ if [ ! -f "$PDFX_DEF" ]; then
70
+ echo "✗ Error: PDF/X definition file not found: $PDFX_DEF" >&2
71
+ exit 1
72
+ fi
73
+
74
+ # Ensure output directory exists
75
+ mkdir -p "$(dirname "$OUTPUT")"
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Build a temporary pdfx-def.ps with the absolute ICC profile path
79
+ # ---------------------------------------------------------------------------
80
+
81
+ # Ghostscript needs an absolute path to the ICC profile in the PostScript
82
+ # definition file. We substitute the placeholder at runtime so the committed
83
+ # .ps file stays portable.
84
+ PDFX_DEF_TMP="$(mktemp "${TMPDIR:-/tmp}/pdfx-def.XXXXXX.ps")"
85
+ trap 'rm -f "$PDFX_DEF_TMP"' EXIT
86
+
87
+ # PostScript string escaping for the title: \, (, ) must be backslash-escaped.
88
+ PS_TITLE="$(printf '%s' "$TITLE" | sed -e 's/\\/\\\\/g' -e 's/(/\\(/g' -e 's/)/\\)/g')"
89
+ ICC_PROFILE="$ICC_PROFILE" PS_TITLE="$PS_TITLE" python3 -c '
90
+ import os, sys
91
+ src = open(sys.argv[1]).read()
92
+ src = src.replace("__ICC_PROFILE_PATH__", os.environ["ICC_PROFILE"]).replace("__TITLE__", os.environ["PS_TITLE"])
93
+ open(sys.argv[2], "w").write(src)
94
+ ' "$PDFX_DEF" "$PDFX_DEF_TMP"
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Strip annotations (PDF/X-1a does not permit non-TrapNet/PrinterMark annots)
98
+ # ---------------------------------------------------------------------------
99
+ # WeasyPrint embeds hyperlink annotations that cause Ghostscript to revert the
100
+ # entire output to normal PDF when -dPDFX is set. Strip them first.
101
+
102
+ STRIPPED_TMP="$(mktemp "${TMPDIR:-/tmp}/book-kit-stripped.XXXXXX.pdf")"
103
+ trap 'rm -f "$PDFX_DEF_TMP" "$STRIPPED_TMP"' EXIT
104
+
105
+ python3 - "$INPUT" "$STRIPPED_TMP" <<'PYEOF'
106
+ import sys
107
+ import pikepdf
108
+
109
+ src, dst = sys.argv[1], sys.argv[2]
110
+ with pikepdf.open(src) as pdf:
111
+ for page in pdf.pages:
112
+ if "/Annots" in page:
113
+ del page["/Annots"]
114
+ pdf.save(dst)
115
+ PYEOF
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Convert to PDF/X-1a:2001
119
+ # ---------------------------------------------------------------------------
120
+
121
+ echo "Converting to PDF/X-1a:2001..."
122
+ echo " Input: $INPUT"
123
+ echo " Output: $OUTPUT"
124
+ echo " ICC: $ICC_PROFILE"
125
+ echo ""
126
+
127
+ gs \
128
+ -dBATCH \
129
+ -dNOPAUSE \
130
+ -dNOOUTERSAVE \
131
+ -dNOSAFER \
132
+ -dQUIET \
133
+ -dPDFX \
134
+ -dCompatibilityLevel=1.3 \
135
+ -sDEVICE=pdfwrite \
136
+ -sColorConversionStrategy=CMYK \
137
+ -sProcessColorModel=DeviceCMYK \
138
+ -dNOTRANSPARENCY \
139
+ -dEmbedAllFonts=true \
140
+ -dSubsetFonts=true \
141
+ -dCompressFonts=true \
142
+ -dCompressPages=true \
143
+ -r300 \
144
+ -dAutoRotatePages=/None \
145
+ -dPDFSETTINGS=/prepress \
146
+ -sOutputFile="$OUTPUT" \
147
+ "$PDFX_DEF_TMP" \
148
+ -f "$STRIPPED_TMP"
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Report results
152
+ # ---------------------------------------------------------------------------
153
+
154
+ if [ -f "$OUTPUT" ]; then
155
+ input_size=$(wc -c < "$INPUT")
156
+ output_size=$(wc -c < "$OUTPUT")
157
+
158
+ # Format file sizes for human readability (pure bash, no bc dependency)
159
+ format_size() {
160
+ local bytes=$1
161
+ if [ "$bytes" -ge 1048576 ]; then
162
+ local mb=$((bytes / 1048576))
163
+ local remainder=$(( (bytes % 1048576) * 10 / 1048576 ))
164
+ echo "${mb}.${remainder} MB"
165
+ elif [ "$bytes" -ge 1024 ]; then
166
+ local kb=$((bytes / 1024))
167
+ local remainder=$(( (bytes % 1024) * 10 / 1024 ))
168
+ echo "${kb}.${remainder} KB"
169
+ else
170
+ echo "$bytes bytes"
171
+ fi
172
+ }
173
+
174
+ echo ""
175
+ echo "✓ PDF/X-1a:2001 conversion complete"
176
+ echo " Input: $(format_size "$input_size") ($INPUT)"
177
+ echo " Output: $(format_size "$output_size") ($OUTPUT)"
178
+
179
+ # Quick check: output should be non-trivially sized
180
+ if [ "$output_size" -lt 1024 ]; then
181
+ echo "" >&2
182
+ echo "⚠ Warning: Output file is suspiciously small ($output_size bytes)." >&2
183
+ echo " The conversion may not have worked correctly." >&2
184
+ exit 1
185
+ fi
186
+
187
+ echo ""
188
+ echo " Next steps:"
189
+ echo " - Validate with: gs -o /dev/null -sDEVICE=nullpage \"$OUTPUT\""
190
+ echo " - Upload to IngramSpark for print distribution"
191
+ else
192
+ echo "✗ Error: Conversion failed. Output file was not created." >&2
193
+ exit 1
194
+ fi
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Locates or downloads the USWebCoatedSWOP.icc CMYK profile (the standard
5
+ # profile for U.S. web offset printing) for PDF/X-1a conversion.
6
+ #
7
+ # The kit doesn't ship the profile. In order of preference it uses:
8
+ # 1. $BOOK_KIT_ICC_PROFILE
9
+ # 2. profiles/USWebCoatedSWOP.icc in the book (a book may vendor it)
10
+ # 3. the user cache ($XDG_CACHE_HOME or ~/.cache)/book-kit/, downloading it
11
+ #
12
+ # --print-path prints the resolved path and exits without downloading.
13
+ #
14
+ # Whatever the source, the exact bytes are pinned by sha256: the mirrors below
15
+ # are untrusted, and a silently-changed CMYK profile would corrupt print color
16
+ # without any build error.
17
+
18
+ if [ -n "${BOOK_KIT_ICC_PROFILE:-}" ]; then
19
+ ICC_FILE="$BOOK_KIT_ICC_PROFILE"
20
+ elif [ -f "${BOOK_ROOT:-.}/profiles/USWebCoatedSWOP.icc" ]; then
21
+ ICC_FILE="${BOOK_ROOT:-.}/profiles/USWebCoatedSWOP.icc"
22
+ else
23
+ ICC_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/book-kit/USWebCoatedSWOP.icc"
24
+ fi
25
+ PROFILES_DIR="$(dirname "$ICC_FILE")"
26
+
27
+ if [ "${1:-}" = "--print-path" ]; then
28
+ echo "$ICC_FILE"
29
+ exit 0
30
+ fi
31
+ EXPECTED_SHA256="35f401731df11a4eba3502af632e51d68bc394bcb7d34632a331c1ba3f4a0bf6"
32
+
33
+ # Verify a file matches the pinned sha256. Uses sha256sum (Linux) or shasum
34
+ # (macOS), whichever is available.
35
+ verify_sha256() {
36
+ local file="$1" actual
37
+ if command -v sha256sum >/dev/null 2>&1; then
38
+ actual="$(sha256sum "$file" | awk '{print $1}')"
39
+ elif command -v shasum >/dev/null 2>&1; then
40
+ actual="$(shasum -a 256 "$file" | awk '{print $1}')"
41
+ else
42
+ # Fail closed: an unverifiable profile could silently corrupt print color.
43
+ echo "✗ Error: no sha256 tool (sha256sum or shasum) found; can't verify $file." >&2
44
+ return 1
45
+ fi
46
+ [ "$actual" = "$EXPECTED_SHA256" ]
47
+ }
48
+
49
+ # Known public sources for USWebCoatedSWOP.icc (tried in order)
50
+ URLS=(
51
+ "https://github.com/theeluwin/CMYK2RGB/raw/master/USWebCoatedSWOP.icc"
52
+ "https://github.com/livgilbert/tiffr/raw/main/USWebCoatedSWOP.icc"
53
+ )
54
+
55
+ if [ -f "$ICC_FILE" ]; then
56
+ if verify_sha256 "$ICC_FILE"; then
57
+ echo "✓ ICC profile already exists and matches pinned sha256: $ICC_FILE"
58
+ echo " Size: $(wc -c < "$ICC_FILE") bytes"
59
+ exit 0
60
+ fi
61
+ echo "✗ Error: $ICC_FILE exists but its sha256 does not match the pinned value." >&2
62
+ echo " Expected: $EXPECTED_SHA256" >&2
63
+ echo " Refusing to use it. Delete it to re-fetch, or restore the vendored copy." >&2
64
+ exit 1
65
+ fi
66
+
67
+ mkdir -p "$PROFILES_DIR"
68
+
69
+ echo "Downloading USWebCoatedSWOP.icc CMYK ICC profile..."
70
+
71
+ for url in "${URLS[@]}"; do
72
+ echo " Trying: $url"
73
+ if curl -fsSL --retry 3 --retry-delay 2 -o "$ICC_FILE" "$url"; then
74
+ # Verify the file is non-empty and looks like an ICC profile
75
+ # ICC profiles start with a 4-byte size field followed by "acsp" at offset 36
76
+ file_size=$(wc -c < "$ICC_FILE")
77
+ if [ "$file_size" -lt 100 ]; then
78
+ echo " ✗ Downloaded file too small ($file_size bytes), trying next source..."
79
+ rm -f "$ICC_FILE"
80
+ continue
81
+ fi
82
+
83
+ if ! verify_sha256 "$ICC_FILE"; then
84
+ echo " ✗ Downloaded file failed sha256 check (mirror may have changed), trying next source..."
85
+ rm -f "$ICC_FILE"
86
+ continue
87
+ fi
88
+
89
+ echo "✓ Downloaded ICC profile: $ICC_FILE ($file_size bytes), sha256 verified"
90
+ exit 0
91
+ else
92
+ echo " ✗ Failed to download from this source, trying next..."
93
+ rm -f "$ICC_FILE"
94
+ fi
95
+ done
96
+
97
+ echo "✗ Error: Could not download ICC profile from any known source." >&2
98
+ echo "" >&2
99
+ echo " You can manually download USWebCoatedSWOP.icc and place it at:" >&2
100
+ echo " $ICC_FILE" >&2
101
+ echo "" >&2
102
+ echo " Sources:" >&2
103
+ echo " - Adobe ICC profiles: https://www.adobe.com/support/downloads/iccprofiles/iccprofiles_win.html" >&2
104
+ echo " - color.org: https://www.color.org/registry/index.xalter" >&2
105
+ exit 1
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ """Build the theme's fonts from the upstream releases, and print where they are.
3
+
4
+ book-kit fonts build (or reuse) the fonts; print their directory
5
+ book-kit fonts --link PATH also point the book's fonts directory at them
6
+
7
+ The kit doesn't ship the Latin subsets and Kindle static instances its theme
8
+ loads. Adobe's Source fonts are OFL-licensed with the Reserved Font Name
9
+ "Source", which bars redistributing modified versions under that name; a
10
+ subset made for your own build, embedded in your book, is fine. So this
11
+ downloads the unmodified upstream files pinned in toolchain.json `fonts`
12
+ (checked against their SHA-256), applies fonts/recipe.json, and caches the result.
13
+
14
+ The cache lives in $BOOK_KIT_CACHE, else $XDG_CACHE_HOME/book-kit, else
15
+ ~/.cache/book-kit, keyed by the recipe, the pinned releases, and the fontTools
16
+ and brotli versions, so a build is a no-op once the fonts exist. The same
17
+ inputs give byte-identical files: nothing is timestamped with the build time.
18
+ """
19
+
20
+ import hashlib
21
+ import io
22
+ import json
23
+ import os
24
+ import shutil
25
+ import sys
26
+ import tempfile
27
+ import urllib.request
28
+ import zipfile
29
+
30
+ # Don't leave a __pycache__ in commands/ (it would ship in the npm package).
31
+ sys.dont_write_bytecode = True
32
+
33
+
34
+ def require_font_tools():
35
+ """Import fontTools and brotli, which only building the fonts needs.
36
+
37
+ Imported here rather than at the top so a book that brings its own fonts
38
+ directory doesn't need either package installed.
39
+ """
40
+ global brotli, fontTools, subset, TTFont, instancer
41
+ try:
42
+ import brotli
43
+ import fontTools
44
+ from fontTools import subset
45
+ from fontTools.ttLib import TTFont
46
+ from fontTools.varLib import instancer
47
+ except ImportError as e:
48
+ print(
49
+ f"book-kit fonts: {e.name} is required. Install the kit's Python packages: "
50
+ "python3 -m pip install -r node_modules/book-kit/requirements.txt",
51
+ file=sys.stderr,
52
+ )
53
+ sys.exit(1)
54
+
55
+
56
+ KIT = os.environ.get("BOOK_KIT_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
57
+
58
+
59
+ def log(msg):
60
+ print(f"book-kit fonts: {msg}", file=sys.stderr)
61
+
62
+
63
+ def cache_root():
64
+ if os.environ.get("BOOK_KIT_CACHE"):
65
+ return os.environ["BOOK_KIT_CACHE"]
66
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
67
+ return os.path.join(base, "book-kit")
68
+
69
+
70
+ def fetch(source, downloads):
71
+ """A pinned file's bytes, downloaded once and checked against its SHA-256."""
72
+ path = os.path.join(downloads, source["sha256"])
73
+ if not os.path.exists(path):
74
+ log(f"downloading {source['url']}")
75
+ with urllib.request.urlopen(source["url"], timeout=120) as r:
76
+ data = r.read()
77
+ digest = hashlib.sha256(data).hexdigest()
78
+ if digest != source["sha256"]:
79
+ sys.exit(f"book-kit fonts: {source['url']} has SHA-256 {digest}, expected {source['sha256']}")
80
+ os.makedirs(downloads, exist_ok=True)
81
+ tmp = f"{path}.{os.getpid()}"
82
+ with open(tmp, "wb") as f:
83
+ f.write(data)
84
+ os.replace(tmp, path)
85
+ with open(path, "rb") as f:
86
+ return f.read()
87
+
88
+
89
+ def load(data):
90
+ # recalcTimestamp=False keeps upstream's head.modified, so output doesn't
91
+ # depend on when it was built.
92
+ return TTFont(io.BytesIO(data), recalcTimestamp=False)
93
+
94
+
95
+ def save(font, path):
96
+ os.makedirs(os.path.dirname(path), exist_ok=True)
97
+ font.flavor = "woff2"
98
+ font.save(path)
99
+
100
+
101
+ def set_name(font, name_id, value):
102
+ """Overwrite every record of name_id (adding a Windows English one if none)."""
103
+ table = font["name"]
104
+ records = [r for r in table.names if r.nameID == name_id]
105
+ for r in records:
106
+ r.string = value
107
+ if not records:
108
+ table.setName(value, name_id, 3, 1, 0x409)
109
+
110
+
111
+ def build(recipe, sources, downloads, out):
112
+ zips = {}
113
+ built = {}
114
+ for job in recipe["subsets"]:
115
+ src = job["source"]
116
+ if "member" in job:
117
+ if src not in zips:
118
+ zips[src] = zipfile.ZipFile(io.BytesIO(fetch(sources[src], downloads)))
119
+ font = load(zips[src].read(job["member"]))
120
+ else:
121
+ font = load(fetch(sources[src], downloads))
122
+ options = subset.Options()
123
+ options.layout_features = recipe["features"][job["features"]]
124
+ options.drop_tables += job.get("drop_tables", [])
125
+ subsetter = subset.Subsetter(options)
126
+ subsetter.populate(unicodes=subset.parse_unicodes(recipe["unicodes"][job["unicodes"]]))
127
+ subsetter.subset(font)
128
+ for name_id, value in job.get("names", {}).items():
129
+ set_name(font, int(name_id), value)
130
+ path = os.path.join(out, job["out"])
131
+ save(font, path)
132
+ with open(path, "rb") as f:
133
+ built[job["out"]] = f.read()
134
+
135
+ for job in recipe["instances"]:
136
+ font = load(built[job["from"]])
137
+ axes = {a.axisTag: a.defaultValue for a in font["fvar"].axes}
138
+ axes.update(job["axes"])
139
+ static = instancer.instantiateVariableFont(font, axes)
140
+ save(static, os.path.join(out, job["out"]))
141
+
142
+
143
+ def kit_link(path, root):
144
+ """Is path a symlink book-kit made: into the font cache, or to this kit's
145
+ fonts/ (where the kit bundled its fonts before building them)?"""
146
+ if not os.path.islink(path):
147
+ return False
148
+ target = os.path.realpath(os.path.join(os.path.dirname(path), os.readlink(path)))
149
+ cache = os.path.realpath(os.path.join(root, "fonts"))
150
+ return target.startswith(cache + os.sep) or target == os.path.realpath(os.path.join(KIT, "fonts"))
151
+
152
+
153
+ def link(path, out, root):
154
+ """Point the book's fonts directory at out, unless the book brought its own:
155
+ a real directory, or a symlink book-kit didn't make, is left alone."""
156
+ if os.path.islink(path) and os.readlink(path) == out:
157
+ return
158
+ os.symlink(out, f"{path}.{os.getpid()}")
159
+ os.replace(f"{path}.{os.getpid()}", path)
160
+ log(f"linked {path} -> {out} (add {path} to .gitignore)")
161
+
162
+
163
+ def main():
164
+ args = sys.argv[1:]
165
+ link_path = None
166
+ if args[:1] == ["--link"] and len(args) == 2:
167
+ link_path = args[1]
168
+ elif args:
169
+ sys.exit("usage: book-kit fonts [--link PATH]")
170
+ root = cache_root()
171
+ if link_path and (os.path.lexists(link_path) and not kit_link(link_path, root)):
172
+ return # The book's own fonts: don't build or touch anything.
173
+ require_font_tools()
174
+
175
+ with open(os.path.join(KIT, "fonts", "recipe.json"), "rb") as f:
176
+ recipe_bytes = f.read()
177
+ with open(os.path.join(KIT, "toolchain.json")) as f:
178
+ sources = json.load(f)["fonts"]
179
+ recipe = json.loads(recipe_bytes)
180
+
181
+ key = hashlib.sha256(
182
+ recipe_bytes
183
+ + json.dumps(sources, sort_keys=True).encode()
184
+ + f"fonttools {fontTools.version} brotli {brotli.__version__}".encode()
185
+ ).hexdigest()[:16]
186
+ out = os.path.join(root, "fonts", key)
187
+
188
+ if not os.path.exists(os.path.join(out, ".complete")):
189
+ os.makedirs(os.path.join(root, "fonts"), exist_ok=True)
190
+ # Build beside the final directory and rename it into place, so an
191
+ # interrupted or concurrent build never leaves a half-written set.
192
+ tmp = tempfile.mkdtemp(prefix=f"{key}.", dir=os.path.join(root, "fonts"))
193
+ try:
194
+ build(recipe, sources, os.path.join(root, "downloads"), tmp)
195
+ open(os.path.join(tmp, ".complete"), "w").close()
196
+ try:
197
+ os.rename(tmp, out)
198
+ except OSError:
199
+ if not os.path.exists(os.path.join(out, ".complete")):
200
+ raise
201
+ finally:
202
+ shutil.rmtree(tmp, ignore_errors=True)
203
+ log(f"built {len(recipe['subsets']) + len(recipe['instances'])} fonts in {out}")
204
+
205
+ if link_path:
206
+ link(link_path, out, root)
207
+ else:
208
+ print(out)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()