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,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()