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,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audit chapter lengths for balance and readability.
|
|
5
|
+
* Flags chapters that are significantly longer or shorter than recommended ranges.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import * as yaml from 'js-yaml';
|
|
11
|
+
import { isNonChapter } from '../lib/structure.js';
|
|
12
|
+
import { pathFor } from '../lib/config.js';
|
|
13
|
+
import { isMain } from '../lib/is-main.js';
|
|
14
|
+
|
|
15
|
+
// Recommended word count range for content chapters. The chapter-lengths
|
|
16
|
+
// validator (validators/chapter-lengths.js) uses these as its defaults.
|
|
17
|
+
const MIN_WORDS = 500;
|
|
18
|
+
const MAX_WORDS = 2500;
|
|
19
|
+
|
|
20
|
+
// Section-opener heading marker. Section openers carry `{.section-opener}` on
|
|
21
|
+
// their H1 (the same marker the print/EPUB CSS and the section-intro
|
|
22
|
+
// validators key off).
|
|
23
|
+
// Matching the marker — rather than a hardcoded filename list — keeps this in
|
|
24
|
+
// sync automatically when chapters are renamed or sections are added.
|
|
25
|
+
const SECTION_OPENER_RE = /^#\s+.*\{[^}]*\.section-opener[^}]*\}/m;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Strip common Markdown/Pandoc markup from content, preserving visible text where possible.
|
|
29
|
+
* This is a heuristic, not a full Markdown parser, but is sufficient for word-count auditing.
|
|
30
|
+
* @param {string} content - Raw Markdown/Pandoc content
|
|
31
|
+
* @returns {string} Plaintext-ish content with markup removed
|
|
32
|
+
*/
|
|
33
|
+
function stripMarkup(content) {
|
|
34
|
+
let stripped = content;
|
|
35
|
+
|
|
36
|
+
// Remove fenced code blocks (``` or ~~~, any language)
|
|
37
|
+
stripped = stripped.replace(/```[\s\S]*?```/g, ' ');
|
|
38
|
+
stripped = stripped.replace(/~~~[\s\S]*?~~~/g, ' ');
|
|
39
|
+
|
|
40
|
+
// Remove inline code marked with backticks
|
|
41
|
+
stripped = stripped.replace(/`[^`]*`/g, ' ');
|
|
42
|
+
|
|
43
|
+
// Images: keep alt text, drop URLs
|
|
44
|
+
stripped = stripped.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1 ');
|
|
45
|
+
|
|
46
|
+
// Links: keep link text, drop URLs
|
|
47
|
+
stripped = stripped.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1 ');
|
|
48
|
+
|
|
49
|
+
// Emphasis and strong emphasis markers (*, _, **, __)
|
|
50
|
+
stripped = stripped.replace(/(\*\*|__)(.*?)\1/g, '$2');
|
|
51
|
+
stripped = stripped.replace(/(\*|_)(.*?)\1/g, '$2');
|
|
52
|
+
|
|
53
|
+
// Headings: remove leading #'s
|
|
54
|
+
stripped = stripped.replace(/^#{1,6}\s+/gm, '');
|
|
55
|
+
|
|
56
|
+
// Blockquotes: remove leading ">"
|
|
57
|
+
stripped = stripped.replace(/^\s{0,3}>\s?/gm, '');
|
|
58
|
+
|
|
59
|
+
// List markers: -, *, +, or numbered lists at line start
|
|
60
|
+
stripped = stripped.replace(/^\s{0,3}([-*+]|\d+\.)\s+/gm, '');
|
|
61
|
+
|
|
62
|
+
// ::: blocks (e.g., Pandoc-style fenced divs)
|
|
63
|
+
// [^\S\n]* (not \s*) so the match can't cross the newline and swallow the
|
|
64
|
+
// paragraph that follows the fence line
|
|
65
|
+
stripped = stripped.replace(/^:::[^\S\n]*[^\n]*$/gm, ' ');
|
|
66
|
+
|
|
67
|
+
// Inline attributes in braces, e.g., {.class}, {#id}
|
|
68
|
+
stripped = stripped.replace(/\{[.#][^}]*\}/g, '');
|
|
69
|
+
|
|
70
|
+
// Collapse all remaining whitespace
|
|
71
|
+
stripped = stripped.replace(/\s+/g, ' ').trim();
|
|
72
|
+
|
|
73
|
+
return stripped;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Count words in a string, excluding Markdown/Pandoc markup
|
|
78
|
+
* @param {string} content - Text content
|
|
79
|
+
* @returns {number} Word count
|
|
80
|
+
*/
|
|
81
|
+
function countWords(content) {
|
|
82
|
+
const plain = stripMarkup(content);
|
|
83
|
+
return plain.length === 0
|
|
84
|
+
? 0
|
|
85
|
+
: plain.split(/\s+/).filter(word => word.length > 0).length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if a file should be excluded from length auditing
|
|
90
|
+
* @param {string} filePath - Path to chapter file
|
|
91
|
+
* @returns {boolean} True if file should be excluded
|
|
92
|
+
*/
|
|
93
|
+
// Structural pages, takeaways collections, and scratch drafts aren't chapters
|
|
94
|
+
// (see lib/structure.js).
|
|
95
|
+
function isExcluded(filePath) {
|
|
96
|
+
return isNonChapter(filePath);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if content belongs to a section introduction.
|
|
101
|
+
*
|
|
102
|
+
* Section openers are intentionally short (≈300–500 words; they frame a section
|
|
103
|
+
* rather than stand alone), so they're exempt from the min-word floor. They're
|
|
104
|
+
* identified by the `{.section-opener}` class on their H1 heading.
|
|
105
|
+
* @param {string} content - Raw chapter content
|
|
106
|
+
* @returns {boolean} True if content is a section intro
|
|
107
|
+
*/
|
|
108
|
+
function isSectionIntro(content) {
|
|
109
|
+
return SECTION_OPENER_RE.test(content);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse index.yml to get chapter list and section mapping
|
|
114
|
+
* @param {string} indexPath - Path to index.yml
|
|
115
|
+
* @returns {{files: string[], sections: Object<string, string>}} Chapter files and section mapping
|
|
116
|
+
*/
|
|
117
|
+
function parseIndex(indexPath = pathFor('index')) {
|
|
118
|
+
if (!fs.existsSync(indexPath)) {
|
|
119
|
+
return { files: [], sections: {} };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const indexContent = fs.readFileSync(indexPath, 'utf8');
|
|
123
|
+
const index = yaml.load(indexContent);
|
|
124
|
+
const files = index['input-files'] || [];
|
|
125
|
+
|
|
126
|
+
const lines = indexContent.split('\n');
|
|
127
|
+
let currentSection = '';
|
|
128
|
+
const sections = {};
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
const sectionMatch = line.match(/^\s*#\s+(.+)/);
|
|
131
|
+
if (sectionMatch) {
|
|
132
|
+
currentSection = sectionMatch[1].trim();
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const fileMatch = line.match(/^\s*-\s+(.+)/);
|
|
136
|
+
if (fileMatch) {
|
|
137
|
+
sections[fileMatch[1].trim()] = currentSection;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { files, sections };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Audit chapter lengths and return warnings
|
|
146
|
+
* @param {string} indexPath - Path to index.yml
|
|
147
|
+
* @param {{min_words?: number, max_words?: number}} [options] - Word count range
|
|
148
|
+
* @returns {{warnings: string[], stats: Object}} Audit results
|
|
149
|
+
*/
|
|
150
|
+
function auditChapterLengths(indexPath = pathFor('index'), { min_words = MIN_WORDS, max_words = MAX_WORDS } = {}) {
|
|
151
|
+
const { files, sections } = parseIndex(indexPath);
|
|
152
|
+
const baseDir = path.dirname(path.resolve(indexPath));
|
|
153
|
+
const warnings = [];
|
|
154
|
+
const chapters = [];
|
|
155
|
+
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
if (isExcluded(file)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resolvedPath = path.resolve(baseDir, file);
|
|
162
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
167
|
+
if (isSectionIntro(content)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const wordCount = countWords(content);
|
|
172
|
+
const section = sections[file] || 'Unknown';
|
|
173
|
+
|
|
174
|
+
chapters.push({ file, wordCount, section });
|
|
175
|
+
|
|
176
|
+
if (wordCount > max_words) {
|
|
177
|
+
warnings.push(
|
|
178
|
+
`Chapter too long (${wordCount} words, max ${max_words}): ${file} [${section}]`
|
|
179
|
+
);
|
|
180
|
+
} else if (wordCount < min_words) {
|
|
181
|
+
warnings.push(
|
|
182
|
+
`Chapter too short (${wordCount} words, min ${min_words}): ${file} [${section}]`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const counts = chapters.map(c => c.wordCount);
|
|
188
|
+
const total = counts.reduce((a, b) => a + b, 0);
|
|
189
|
+
const stats = {
|
|
190
|
+
chapterCount: chapters.length,
|
|
191
|
+
totalWords: total,
|
|
192
|
+
averageWords: chapters.length > 0 ? Math.round(total / chapters.length) : 0,
|
|
193
|
+
minWords: chapters.length > 0 ? Math.min(...counts) : 0,
|
|
194
|
+
maxWords: chapters.length > 0 ? Math.max(...counts) : 0,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return { warnings, stats, chapters };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Print audit results to console
|
|
202
|
+
* @param {string} indexPath - Path to index.yml
|
|
203
|
+
* @returns {number} Number of warnings
|
|
204
|
+
*/
|
|
205
|
+
function printAudit(indexPath = pathFor('index')) {
|
|
206
|
+
const { warnings, stats } = auditChapterLengths(indexPath);
|
|
207
|
+
|
|
208
|
+
console.log(`Chapter length audit (${stats.chapterCount} content chapters)`);
|
|
209
|
+
console.log(` Range: ${MIN_WORDS}–${MAX_WORDS} words`);
|
|
210
|
+
console.log(` Average: ${stats.averageWords} words`);
|
|
211
|
+
console.log(` Min: ${stats.minWords} words`);
|
|
212
|
+
console.log(` Max: ${stats.maxWords} words`);
|
|
213
|
+
console.log('');
|
|
214
|
+
|
|
215
|
+
if (warnings.length === 0) {
|
|
216
|
+
console.log('✓ All chapters within recommended length range');
|
|
217
|
+
} else {
|
|
218
|
+
for (const warning of warnings) {
|
|
219
|
+
console.log(`⚠ ${warning}`);
|
|
220
|
+
}
|
|
221
|
+
console.log(`\n⚠ ${warnings.length} chapter(s) outside recommended range`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return warnings.length;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Export for testing
|
|
228
|
+
export {
|
|
229
|
+
MIN_WORDS,
|
|
230
|
+
MAX_WORDS,
|
|
231
|
+
countWords,
|
|
232
|
+
isExcluded,
|
|
233
|
+
isSectionIntro,
|
|
234
|
+
parseIndex,
|
|
235
|
+
auditChapterLengths,
|
|
236
|
+
printAudit
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Run if executed directly
|
|
240
|
+
if (isMain(import.meta.url)) {
|
|
241
|
+
const indexPath = process.argv[2] || pathFor('index');
|
|
242
|
+
const warningCount = printAudit(indexPath);
|
|
243
|
+
process.exit(warningCount > 0 ? 1 : 0);
|
|
244
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EPUB size budget checker.
|
|
5
|
+
* Fails CI if any EPUB exceeds its size threshold.
|
|
6
|
+
*
|
|
7
|
+
* Usage: book-kit check-epub-size [--ci]
|
|
8
|
+
* --ci: exit with non-zero code on budget violation (default: warn only)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, statSync } from 'node:fs';
|
|
12
|
+
import { relative } from 'node:path';
|
|
13
|
+
import { loadConfig, outputFile } from '../lib/config.js';
|
|
14
|
+
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
const rootDir = config.root;
|
|
17
|
+
const ci = process.argv.includes('--ci');
|
|
18
|
+
|
|
19
|
+
// Size budget in bytes, from checks.epub_max_mb (MB = 1024 × 1024 bytes).
|
|
20
|
+
const budget = (config.checks?.epub_max_mb ?? 2) * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
// The retail EPUBs always; the advance reader copies when they've been built.
|
|
23
|
+
const budgets = Object.fromEntries(
|
|
24
|
+
['.epub', '-kindle.epub', '-arc.epub', '-kindle-arc.epub']
|
|
25
|
+
.map((suffix) => outputFile(suffix, config))
|
|
26
|
+
.filter((file, i) => i < 2 || existsSync(file))
|
|
27
|
+
.map((file) => [relative(rootDir, file), budget]),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function formatSize(bytes) {
|
|
31
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
32
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
33
|
+
return `${bytes} B`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let violations = 0;
|
|
37
|
+
|
|
38
|
+
console.log('EPUB Size Budget Report');
|
|
39
|
+
console.log('═══════════════════════');
|
|
40
|
+
|
|
41
|
+
for (const [file, budget] of Object.entries(budgets)) {
|
|
42
|
+
const path = `${rootDir}/${file}`;
|
|
43
|
+
try {
|
|
44
|
+
const { size } = statSync(path);
|
|
45
|
+
const pct = ((size / budget) * 100).toFixed(1);
|
|
46
|
+
const status = size > budget ? '❌ OVER' : size > budget * 0.9 ? '⚠️ WARN' : '✅ OK';
|
|
47
|
+
|
|
48
|
+
console.log(`\n${file}:`);
|
|
49
|
+
console.log(` Size: ${formatSize(size)}`);
|
|
50
|
+
console.log(` Budget: ${formatSize(budget)}`);
|
|
51
|
+
console.log(` Usage: ${pct}% ${status}`);
|
|
52
|
+
|
|
53
|
+
if (size > budget) {
|
|
54
|
+
violations++;
|
|
55
|
+
console.log(` → Exceeds budget by ${formatSize(size - budget)}`);
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
console.log(`\n${file}: not found (skipped)`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log('\n───────────────────────');
|
|
63
|
+
if (violations > 0) {
|
|
64
|
+
console.log(`${violations} file(s) exceeded their size budget.`);
|
|
65
|
+
if (ci) process.exit(1);
|
|
66
|
+
} else {
|
|
67
|
+
console.log('All files within budget.');
|
|
68
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lock the print PDF's page count to a known value so it can't drift, and
|
|
5
|
+
* check its page size against print.trim.
|
|
6
|
+
*
|
|
7
|
+
* The page count is load-bearing launch metadata: it drives the print spine
|
|
8
|
+
* width (and therefore the cover wrap), the page-count tier that sets the
|
|
9
|
+
* gutter margin, and the figure the designer and distributors are given. A
|
|
10
|
+
* silent change to chapter length, CSS, or fonts can shift it by a page or
|
|
11
|
+
* two — enough to make a pre-cut spine wrong. This gate fails loudly when the
|
|
12
|
+
* built PDF no longer matches EXPECTED_PAGE_COUNT.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* book-kit check-pdf-page-count [path-to-pdf]
|
|
16
|
+
*
|
|
17
|
+
* Defaults to the print PDF (outputFile('.pdf')). The PDF/X file is the same
|
|
18
|
+
* interior, so it shares the same expected count.
|
|
19
|
+
*
|
|
20
|
+
* To change the locked value INTENTIONALLY (e.g. after deliberate content
|
|
21
|
+
* changes), update print.page_count in book.config.yml, then recut the cover
|
|
22
|
+
* spine and update the page count wherever the book is listed.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { resolve } from 'node:path';
|
|
28
|
+
import { loadConfig, outputFile } from '../lib/config.js';
|
|
29
|
+
import { isMain } from '../lib/is-main.js';
|
|
30
|
+
|
|
31
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
32
|
+
|
|
33
|
+
// The locked page count for the print interior (`print.page_count` in
|
|
34
|
+
// book.config.yml; null disables the check).
|
|
35
|
+
//
|
|
36
|
+
// Lock it once the book is final: the page count drives the cover spine width,
|
|
37
|
+
// the gutter-margin tier, and distributor metadata, so a silent drift (a
|
|
38
|
+
// pandoc or WeasyPrint upgrade, a hyphenation change, a cascade reorder) can
|
|
39
|
+
// invalidate a print cover. Pandoc's version is the dominant lever on
|
|
40
|
+
// pagination, which is why toolchain.json pins it. Perfect-bound interiors
|
|
41
|
+
// must have an even page count.
|
|
42
|
+
export function expectedPageCount(config = loadConfig()) {
|
|
43
|
+
return config.print.page_count ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Count pages in a PDF without any external dependency.
|
|
48
|
+
*
|
|
49
|
+
* Counts page objects (`/Type /Page`, excluding the `/Pages` tree node) in the
|
|
50
|
+
* raw PDF. This matches pdfinfo/qpdf for the WeasyPrint-generated output, which
|
|
51
|
+
* writes uncompressed page objects. If a future toolchain change emits an
|
|
52
|
+
* object-stream / compressed-xref PDF, the page objects would be hidden inside
|
|
53
|
+
* compressed streams and this count would collapse — so we throw rather than
|
|
54
|
+
* silently report 0, pointing at the likely cause.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} pdfPath absolute or relative path to the PDF
|
|
57
|
+
* @returns {number} page count
|
|
58
|
+
*/
|
|
59
|
+
export function countPdfPages(pdfPath) {
|
|
60
|
+
const raw = readFileSync(pdfPath, 'latin1');
|
|
61
|
+
// `/Type /Page` but not `/Type /Pages` (the page-tree root). The trailing
|
|
62
|
+
// \b after "Page" fails when followed by "s" (both word chars), so `/Pages`
|
|
63
|
+
// is excluded; it matches when "Page" is followed by /, whitespace, etc.
|
|
64
|
+
const pages = (raw.match(/\/Type\s*\/Page\b/g) || []).length;
|
|
65
|
+
|
|
66
|
+
if (pages === 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Counted 0 pages in ${pdfPath}. The PDF may now use object streams / ` +
|
|
69
|
+
`a compressed cross-reference table, hiding page objects from this ` +
|
|
70
|
+
`text-based counter. Update countPdfPages() (e.g. use pdfinfo/qpdf).`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return pages;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every distinct page size in a PDF, in points, from its `/MediaBox` entries
|
|
79
|
+
* (same uncompressed-object caveat as countPdfPages).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} pdfPath
|
|
82
|
+
* @returns {Array<[number, number]>} [width, height] pairs
|
|
83
|
+
*/
|
|
84
|
+
export function pdfPageSizes(pdfPath) {
|
|
85
|
+
const raw = readFileSync(pdfPath, 'latin1');
|
|
86
|
+
const sizes = new Map();
|
|
87
|
+
for (const [, box] of raw.matchAll(/\/MediaBox\s*\[([^\]]*)\]/g)) {
|
|
88
|
+
const [x0, y0, x1, y1] = box.trim().split(/\s+/).map(Number);
|
|
89
|
+
const size = [Math.abs(x1 - x0), Math.abs(y1 - y0)];
|
|
90
|
+
sizes.set(size.join('x'), size);
|
|
91
|
+
}
|
|
92
|
+
return [...sizes.values()];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Page sizes that don't match print.trim (inches; a PDF point is 1/72 in).
|
|
97
|
+
* print.trim is what the book tells printers and retailers; the CSS @page
|
|
98
|
+
* size is what WeasyPrint actually lays out, so a mismatch between the two
|
|
99
|
+
* would ship a wrong-sized interior.
|
|
100
|
+
*
|
|
101
|
+
* @returns {string[]} one message per mismatched size; empty when they agree
|
|
102
|
+
*/
|
|
103
|
+
export function trimMismatches(sizes, trim) {
|
|
104
|
+
if (!Array.isArray(trim) || trim.length !== 2) return [];
|
|
105
|
+
const [w, h] = trim.map((inches) => inches * 72);
|
|
106
|
+
const near = (a, b) => Math.abs(a - b) < 1;
|
|
107
|
+
return sizes
|
|
108
|
+
.filter(([pw, ph]) => !(near(pw, w) && near(ph, h)))
|
|
109
|
+
.map(([pw, ph]) => `${+(pw / 72).toFixed(3)}×${+(ph / 72).toFixed(3)} in`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** CLI entry point. */
|
|
113
|
+
function main() {
|
|
114
|
+
const arg = process.argv[2];
|
|
115
|
+
const pdfPath = arg ? resolve(arg) : outputFile('.pdf');
|
|
116
|
+
const EXPECTED_PAGE_COUNT = expectedPageCount();
|
|
117
|
+
|
|
118
|
+
if (!existsSync(pdfPath)) {
|
|
119
|
+
console.error(`❌ PDF not found: ${pdfPath}`);
|
|
120
|
+
console.error(' Build it first (e.g. `just pdf`), then re-run this check.');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const actual = countPdfPages(pdfPath);
|
|
125
|
+
|
|
126
|
+
const trim = loadConfig().print.trim;
|
|
127
|
+
const wrongSizes = trimMismatches(pdfPageSizes(pdfPath), trim);
|
|
128
|
+
if (wrongSizes.length > 0) {
|
|
129
|
+
console.error(`❌ Page size mismatch: ${pdfPath} has ${wrongSizes.join(', ')} pages; print.trim is ${trim[0]}×${trim[1]} in.`);
|
|
130
|
+
console.error(' Make the @page size in the book\'s CSS match print.trim (or fix print.trim).');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (EXPECTED_PAGE_COUNT === null) {
|
|
135
|
+
console.log(`ℹ️ ${pdfPath}: ${actual} pages (no lock; set print.page_count in book.config.yml to lock it).`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (actual !== EXPECTED_PAGE_COUNT) {
|
|
140
|
+
console.error(
|
|
141
|
+
`❌ Page count drift: ${pdfPath} has ${actual} pages, expected ${EXPECTED_PAGE_COUNT}.`
|
|
142
|
+
);
|
|
143
|
+
console.error(
|
|
144
|
+
' The print page count is locked because it drives the spine width,'
|
|
145
|
+
);
|
|
146
|
+
console.error(
|
|
147
|
+
' gutter-margin tier, and distributor metadata. If this change is'
|
|
148
|
+
);
|
|
149
|
+
console.error(
|
|
150
|
+
' intentional, update print.page_count in book.config.yml,'
|
|
151
|
+
);
|
|
152
|
+
console.error(' then recut the cover spine and refresh the metadata to match.');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(`✅ ${pdfPath}: ${actual} pages (matches locked value ${EXPECTED_PAGE_COUNT}).`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Run as CLI only when invoked directly, not when imported by tests.
|
|
160
|
+
if (isMain(import.meta.url)) {
|
|
161
|
+
main();
|
|
162
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate Kindle-specific emoji PNGs: transparent, but white-safe when flattened.
|
|
3
|
+
|
|
4
|
+
Background: two approaches have been tried.
|
|
5
|
+
Baking an opaque callout-colored background hid the box on a light-mode e-ink
|
|
6
|
+
Paperwhite, but on Kindle iOS — whose page is pure white and which doesn't paint
|
|
7
|
+
the callout tint behind the image — that baked tint shows as a colored rectangle
|
|
8
|
+
around every callout emoji. Plain transparent RGBA, on the other hand, can flatten
|
|
9
|
+
to Twemoji's *dark slate* transparent-pixel RGB on renderers that drop alpha,
|
|
10
|
+
producing a dark box.
|
|
11
|
+
|
|
12
|
+
This version takes the middle path that's correct on modern renderers and no worse
|
|
13
|
+
than white anywhere else: keep the original alpha (true transparency, clean edges),
|
|
14
|
+
but rewrite the RGB of fully-transparent pixels to white. So:
|
|
15
|
+
|
|
16
|
+
- Renderer honors alpha (Kindle iOS, modern KFX) → genuinely transparent, no box,
|
|
17
|
+
clean in light / sepia / dark mode. (This is the case we're fixing.)
|
|
18
|
+
- Renderer flattens alpha (older e-ink) → transparent areas show WHITE, not dark
|
|
19
|
+
slate: invisible on a white page, at worst a white box in dark/sepia mode.
|
|
20
|
+
|
|
21
|
+
No per-callout background is baked anymore — that was the source of the iOS colored
|
|
22
|
+
box. All emoji are treated identically.
|
|
23
|
+
|
|
24
|
+
Input: SRC_DIR/*.png (palette/RGBA, transparent, dark-slate under alpha)
|
|
25
|
+
Output: DST_DIR/*.png (RGBA, transparent, white under alpha)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
# Don't leave a __pycache__ in commands/ (it would ship in the npm package).
|
|
32
|
+
sys.dont_write_bytecode = True
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from PIL import Image
|
|
36
|
+
except ImportError:
|
|
37
|
+
print("Error: Pillow is required. Install with: pip install Pillow", file=sys.stderr)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
from _emoji_io import save_if_changed
|
|
41
|
+
|
|
42
|
+
if len(sys.argv) != 3:
|
|
43
|
+
print("Usage: composite-kindle-emoji.py SRC_DIR DST_DIR", file=sys.stderr)
|
|
44
|
+
sys.exit(2)
|
|
45
|
+
SRC_DIR, DST_DIR = sys.argv[1], sys.argv[2]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def whiten_transparent(src_path, dst_path):
|
|
49
|
+
"""Keep alpha; set the RGB of fully-transparent pixels to white.
|
|
50
|
+
|
|
51
|
+
convert("RGBA") normalizes palette + tRNS sources (which some Kindle
|
|
52
|
+
renderers mishandle). Image.composite(orig, white, mask) keeps the original
|
|
53
|
+
channel where the pixel is visible (mask=255) and substitutes white where it
|
|
54
|
+
is fully transparent (mask=0), leaving the alpha channel untouched so true
|
|
55
|
+
transparency — and clean anti-aliased edges — survive.
|
|
56
|
+
"""
|
|
57
|
+
src = Image.open(src_path).convert("RGBA")
|
|
58
|
+
r, g, b, a = src.split()
|
|
59
|
+
white = Image.new("L", src.size, 255)
|
|
60
|
+
# mask: 255 for any visible pixel (alpha > 0), 0 for fully transparent.
|
|
61
|
+
mask = a.point(lambda v: 255 if v > 0 else 0)
|
|
62
|
+
r = Image.composite(r, white, mask)
|
|
63
|
+
g = Image.composite(g, white, mask)
|
|
64
|
+
b = Image.composite(b, white, mask)
|
|
65
|
+
return save_if_changed(Image.merge("RGBA", (r, g, b, a)), dst_path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
os.makedirs(DST_DIR, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
count = written = 0
|
|
72
|
+
for filename in sorted(os.listdir(SRC_DIR)):
|
|
73
|
+
if not filename.endswith(".png"):
|
|
74
|
+
continue
|
|
75
|
+
written += whiten_transparent(
|
|
76
|
+
os.path.join(SRC_DIR, filename),
|
|
77
|
+
os.path.join(DST_DIR, filename),
|
|
78
|
+
)
|
|
79
|
+
count += 1
|
|
80
|
+
|
|
81
|
+
print(f"Kindle emoji PNGs (transparent, white under alpha) in {DST_DIR}: {count}, {written} rewritten")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate print-specific emoji PNGs: fully opaque, flattened onto the exact
|
|
3
|
+
background each emoji sits on in the PDF.
|
|
4
|
+
|
|
5
|
+
Why this exists: the print PDF is
|
|
6
|
+
converted to PDF/X-1a:2001 for IngramSpark by commands/convert-pdfx.sh, which runs
|
|
7
|
+
Ghostscript with -dNOTRANSPARENCY. That flag is required — it keeps the
|
|
8
|
+
interior as vector text (without it, Ghostscript rasterizes every page to a
|
|
9
|
+
300 DPI bitmap: ~58 MB, no embedded fonts, no extractable text). But
|
|
10
|
+
-dNOTRANSPARENCY drops each image's soft-mask (SMask) instead of compositing it,
|
|
11
|
+
so a transparent Twemoji PNG loses its alpha and reveals the dark slate RGB
|
|
12
|
+
Twemoji stores in "transparent" pixels — the 💡/👔/💻/🤔 callout labels then
|
|
13
|
+
print as dark boxes.
|
|
14
|
+
|
|
15
|
+
Fix: hand Ghostscript images that have no alpha at all. Flatten each Twemoji onto
|
|
16
|
+
the solid colour it actually sits on, so there is no SMask to drop and no box to
|
|
17
|
+
reveal — matching pixels above and below:
|
|
18
|
+
|
|
19
|
+
- Callout-label emoji (💡 👔 💻 🤔) always sit on the print callout fill
|
|
20
|
+
(#e8e8e8, set in css/theme/11-print.css). Flatten onto #e8e8e8.
|
|
21
|
+
- Body-text emoji sit on the white page. Flatten onto white.
|
|
22
|
+
|
|
23
|
+
Both output sets are fully opaque RGB (mode "RGB", no alpha channel), so
|
|
24
|
+
Ghostscript leaves them untouched and the CMYK conversion treats them as ordinary
|
|
25
|
+
opaque artwork. The callout fill (#e8e8e8) is the same source RGB as the baked
|
|
26
|
+
emoji background, so both convert to the same CMYK and stay seamless.
|
|
27
|
+
|
|
28
|
+
Only the PDF pipeline uses these (justfile `pdf` recipe). EPUB/Kindle/web keep the
|
|
29
|
+
transparent originals in img/emoji/ (they render alpha correctly).
|
|
30
|
+
|
|
31
|
+
Input: SRC_DIR/*.png (RGBA, transparent, dark-slate under alpha)
|
|
32
|
+
Output: CALLOUT_DST_DIR/*.png (RGB, opaque, flattened onto #e8e8e8)
|
|
33
|
+
BODY_DST_DIR/*.png (RGB, opaque, flattened onto white)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
# Don't leave a __pycache__ in commands/ (it would ship in the npm package).
|
|
40
|
+
sys.dont_write_bytecode = True
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from PIL import Image
|
|
44
|
+
except ImportError:
|
|
45
|
+
print("Error: Pillow is required. Install with: pip install Pillow", file=sys.stderr)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
from _emoji_io import save_if_changed
|
|
49
|
+
|
|
50
|
+
if len(sys.argv) != 4:
|
|
51
|
+
print("Usage: composite-print-emoji.py SRC_DIR CALLOUT_DST_DIR BODY_DST_DIR", file=sys.stderr)
|
|
52
|
+
sys.exit(2)
|
|
53
|
+
SRC_DIR = sys.argv[1]
|
|
54
|
+
|
|
55
|
+
# (output dir, background RGB) — must match the backdrop each emoji sits on.
|
|
56
|
+
# Callout fill #e8e8e8 is defined in the kit theme (css/theme/11-print.css, @media print,
|
|
57
|
+
# all callout classes). Body emoji sit on the white page.
|
|
58
|
+
TARGETS = [
|
|
59
|
+
(sys.argv[2], (0xE8, 0xE8, 0xE8)),
|
|
60
|
+
(sys.argv[3], (0xFF, 0xFF, 0xFF)),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def flatten_onto(src_path, dst_path, bg_rgb):
|
|
65
|
+
"""Composite a transparent RGBA emoji onto a solid background and drop alpha.
|
|
66
|
+
|
|
67
|
+
The result is mode "RGB" (no alpha channel at all), so Ghostscript's
|
|
68
|
+
-dNOTRANSPARENCY has no soft-mask to discard: the image is already opaque and
|
|
69
|
+
prints exactly as composited, anti-aliased edges blended into bg_rgb.
|
|
70
|
+
"""
|
|
71
|
+
src = Image.open(src_path).convert("RGBA")
|
|
72
|
+
bg = Image.new("RGBA", src.size, bg_rgb + (255,))
|
|
73
|
+
bg.alpha_composite(src)
|
|
74
|
+
return save_if_changed(bg.convert("RGB"), dst_path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main():
|
|
78
|
+
total = 0
|
|
79
|
+
for dst_dir, bg_rgb in TARGETS:
|
|
80
|
+
os.makedirs(dst_dir, exist_ok=True)
|
|
81
|
+
count = written = 0
|
|
82
|
+
for filename in sorted(os.listdir(SRC_DIR)):
|
|
83
|
+
if not filename.endswith(".png"):
|
|
84
|
+
continue
|
|
85
|
+
written += flatten_onto(
|
|
86
|
+
os.path.join(SRC_DIR, filename),
|
|
87
|
+
os.path.join(dst_dir, filename),
|
|
88
|
+
bg_rgb,
|
|
89
|
+
)
|
|
90
|
+
count += 1
|
|
91
|
+
print(f"Opaque emoji PNGs (bg #{bg_rgb[0]:02x}{bg_rgb[1]:02x}{bg_rgb[2]:02x}) in {dst_dir}/: {count}, {written} rewritten")
|
|
92
|
+
total += count
|
|
93
|
+
|
|
94
|
+
print(f"Total: {total} print emoji PNGs.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|