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.
- package/LICENSE +21 -0
- package/NOTICE.md +72 -0
- package/README.md +251 -2
- package/bin/book-kit.js +115 -0
- package/commands/_emoji_io.py +32 -0
- package/commands/audit-chapter-lengths.js +244 -0
- package/commands/check-epub-size.js +68 -0
- package/commands/check-pdf-page-count.js +162 -0
- package/commands/composite-kindle-emoji.py +85 -0
- package/commands/composite-print-emoji.py +98 -0
- package/commands/conventions.js +76 -0
- package/commands/convert-pdfx.sh +194 -0
- package/commands/download-icc-profile.sh +105 -0
- package/commands/fonts.py +212 -0
- package/commands/generate-reference-docx.py +344 -0
- package/commands/kindle-fonts.js +173 -0
- package/commands/links-to-footnotes.js +366 -0
- package/commands/markdownlint.js +25 -0
- package/commands/minify-css.js +43 -0
- package/commands/optimize-images.js +73 -0
- package/commands/parity.sh +150 -0
- package/commands/postprocess-epub.js +541 -0
- package/commands/prep-print-src.js +77 -0
- package/commands/prose.js +38 -0
- package/commands/release-files.js +89 -0
- package/commands/reorder-print-frontmatter.js +79 -0
- package/commands/strip-page-rules.js +1168 -0
- package/commands/strip-property-rules.js +93 -0
- package/commands/update-key-takeaways.js +314 -0
- package/commands/validate.js +283 -0
- package/configs/markdownlint-cli2.jsonc +98 -0
- package/configs/textlintrc.json +22 -0
- package/css/theme/01-tokens.css +83 -0
- package/css/theme/02-fonts.css +79 -0
- package/css/theme/03-prose.css +53 -0
- package/css/theme/04-base.css +226 -0
- package/css/theme/05-components.css +546 -0
- package/css/theme/06-utilities.css +40 -0
- package/css/theme/07-ereader.css +245 -0
- package/css/theme/08-epub.css +865 -0
- package/css/theme/09-back-matter.css +47 -0
- package/css/theme/10-dark.css +302 -0
- package/css/theme/11-print.css +1229 -0
- package/css/theme.css +38 -0
- package/defaults/book.config.yml +318 -0
- package/defaults/pandoc-base.yml +19 -0
- package/defaults/pandoc-docx.yml +6 -0
- package/defaults/pandoc-epub-fonts.yml +6 -0
- package/defaults/pandoc-epub.yml +15 -0
- package/defaults/pandoc-kindle.yml +6 -0
- package/emoji/1f382.png +0 -0
- package/emoji/1f389.png +0 -0
- package/emoji/1f440.png +0 -0
- package/emoji/1f44d.png +0 -0
- package/emoji/1f44f.png +0 -0
- package/emoji/1f454.png +0 -0
- package/emoji/1f4a1.png +0 -0
- package/emoji/1f4bb.png +0 -0
- package/emoji/1f602.png +0 -0
- package/emoji/1f603.png +0 -0
- package/emoji/1f604.png +0 -0
- package/emoji/1f60a.png +0 -0
- package/emoji/1f60f.png +0 -0
- package/emoji/1f64c.png +0 -0
- package/emoji/1f64f.png +0 -0
- package/emoji/1f914.png +0 -0
- package/emoji/1f923.png +0 -0
- package/emoji/2714.png +0 -0
- package/emoji/274c.png +0 -0
- package/filters/add-div-titles.lua +196 -0
- package/filters/arc-notice.lua +36 -0
- package/filters/body-emoji-images.lua +48 -0
- package/filters/callout-classes.lua +40 -0
- package/filters/lib/book_kit.lua +110 -0
- package/filters/revision.lua +17 -0
- package/filters/strip-comments.lua +40 -0
- package/filters/strip-emoji-kindle.lua +134 -0
- package/fonts/LICENSE-source-code-pro.md +93 -0
- package/fonts/LICENSE-source-sans.md +93 -0
- package/fonts/LICENSE-source-serif.md +93 -0
- package/fonts/recipe.json +41 -0
- package/just/book-kit.just +229 -0
- package/lib/acronyms.js +47 -0
- package/lib/callouts.js +17 -0
- package/lib/config.js +211 -0
- package/lib/is-main.js +21 -0
- package/lib/lint.js +148 -0
- package/lib/pandoc.js +148 -0
- package/lib/paragraphs.js +173 -0
- package/lib/revision.js +35 -0
- package/lib/structure.js +39 -0
- package/lib/textlint-github-formatter.js +58 -0
- package/lib/validate.js +202 -0
- package/package.json +98 -4
- package/presets/business-nonfiction/CONVENTIONS.md +35 -0
- package/presets/business-nonfiction/preset.yml +128 -0
- package/profiles/pdfx-def.ps +42 -0
- package/requirements.txt +14 -0
- package/rules/no-ai-like-patterns.js +209 -0
- package/rules/no-assumptive.js +76 -0
- package/rules/no-bare-acronyms.js +129 -0
- package/rules/no-cliches.js +544 -0
- package/rules/no-cultural-idioms.js +422 -0
- package/rules/no-vague-intensifiers.js +94 -0
- package/templates/template-epub.html +79 -0
- package/templates/template.html +73 -0
- package/toolchain.json +42 -0
- package/validators/acronym-expansion.js +94 -0
- package/validators/bold-keywords.js +56 -0
- package/validators/callout-balance.js +74 -0
- package/validators/callout-divs.js +66 -0
- package/validators/callout-labels.js +102 -0
- package/validators/chapter-lengths.js +14 -0
- package/validators/chapter.js +57 -0
- package/validators/cross-references.js +45 -0
- package/validators/doubled-words.js +76 -0
- package/validators/duplicate-headings.js +52 -0
- package/validators/em-dashes.js +73 -0
- package/validators/escaped-smallcaps.js +46 -0
- package/validators/footnotes.js +78 -0
- package/validators/heading-hierarchy.js +58 -0
- package/validators/hypothetical-hooks.js +103 -0
- package/validators/index-integrity.js +28 -0
- package/validators/index.js +73 -0
- package/validators/list-punctuation.js +123 -0
- package/validators/min-opener-length.js +87 -0
- package/validators/no-bare-url-link-text.js +38 -0
- package/validators/no-todo-markers.js +49 -0
- package/validators/no-transition-todos.js +34 -0
- package/validators/number-range-dashes.js +72 -0
- package/validators/paragraph-length.js +62 -0
- package/validators/quote-punctuation.js +85 -0
- package/validators/section-intro-final-lead.js +90 -0
- package/validators/section-intro-has-lead.js +41 -0
- package/validators/section-intro-no-tldr.js +37 -0
- package/validators/section-intros.js +33 -0
- package/validators/sentence-starters.js +132 -0
- package/validators/smallcaps.js +67 -0
- 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()
|