@thegetty/quire-cli 1.0.0-rc.35 → 1.0.0-rc.36

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 (158) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +9 -0
  3. package/bin/cli.js +19 -1
  4. package/package.json +21 -8
  5. package/patches/README.md +19 -0
  6. package/patches/install-npm-version+1.0.9.patch +12119 -0
  7. package/schemas/objects.schema.json +12 -38
  8. package/schemas/publication.schema.json +0 -22
  9. package/schemas/references.schema.json +0 -1
  10. package/src/Command.js +26 -6
  11. package/src/Command.spec.js +99 -0
  12. package/src/commands/README.md +213 -122
  13. package/src/commands/build.js +46 -37
  14. package/src/commands/build.spec.js +113 -0
  15. package/src/commands/build.test.js +402 -0
  16. package/src/commands/clean.js +25 -19
  17. package/src/commands/clean.spec.js +108 -0
  18. package/src/commands/clean.test.js +260 -0
  19. package/src/commands/config.js +251 -0
  20. package/src/commands/config.spec.js +107 -0
  21. package/src/commands/config.test.js +715 -0
  22. package/src/commands/create.js +42 -14
  23. package/src/commands/create.spec.js +112 -0
  24. package/src/commands/create.test.js +415 -0
  25. package/src/commands/epub.js +59 -30
  26. package/src/commands/epub.spec.js +114 -0
  27. package/src/commands/epub.test.js +503 -0
  28. package/src/commands/index.js +9 -2
  29. package/src/commands/info.js +18 -24
  30. package/src/commands/info.spec.js +64 -0
  31. package/src/commands/info.test.js +415 -0
  32. package/src/commands/pdf.js +58 -84
  33. package/src/commands/pdf.spec.js +114 -0
  34. package/src/commands/pdf.test.js +464 -0
  35. package/src/commands/preview.js +26 -31
  36. package/src/commands/preview.spec.js +97 -0
  37. package/src/commands/preview.test.js +250 -0
  38. package/src/commands/use.js +56 -0
  39. package/src/commands/use.spec.js +61 -0
  40. package/src/commands/use.test.js +280 -0
  41. package/src/commands/validate.js +31 -19
  42. package/src/commands/validate.spec.js +82 -0
  43. package/src/commands/validate.test.js +234 -0
  44. package/src/commands/workflows.js +70 -0
  45. package/src/errors/build/build-failed-error.js +19 -0
  46. package/src/errors/build/config-field-missing-error.js +20 -0
  47. package/src/errors/build/config-file-not-found-error.js +20 -0
  48. package/src/errors/build/index.js +11 -0
  49. package/src/errors/index.js +48 -0
  50. package/src/errors/install/dependency-install-error.js +19 -0
  51. package/src/errors/install/directory-not-empty-error.js +25 -0
  52. package/src/errors/install/index.js +12 -0
  53. package/src/errors/install/invalid-path-error.js +31 -0
  54. package/src/errors/install/invalid-starter-error.js +27 -0
  55. package/src/errors/install/version-not-found-error.js +35 -0
  56. package/src/errors/output/epub-generation-error.js +19 -0
  57. package/src/errors/output/index.js +14 -0
  58. package/src/errors/output/invalid-epub-library-error.js +20 -0
  59. package/src/errors/output/invalid-pdf-library-error.js +20 -0
  60. package/src/errors/output/missing-build-output-error.js +21 -0
  61. package/src/errors/output/pdf-generation-error.js +24 -0
  62. package/src/errors/output/tool-not-found-error.js +37 -0
  63. package/src/errors/project/index.js +10 -0
  64. package/src/errors/project/not-in-project-error.js +20 -0
  65. package/src/errors/project/project-create-error.js +21 -0
  66. package/src/errors/quire-error.js +27 -0
  67. package/src/errors/validation/validation-error.js +20 -11
  68. package/src/helpers/clean.js +1 -1
  69. package/src/helpers/docs-url.js +32 -0
  70. package/src/helpers/test-cwd.js +5 -6
  71. package/src/helpers/test-cwd.test.js +192 -0
  72. package/src/helpers/which.js +10 -4
  73. package/src/lib/11ty/README.md +135 -19
  74. package/src/lib/11ty/api.js +176 -93
  75. package/src/lib/11ty/cli.js +77 -35
  76. package/src/lib/11ty/index.js +64 -5
  77. package/src/lib/11ty/index.test.js +655 -0
  78. package/src/lib/README.md +275 -0
  79. package/src/lib/commander/index.js +100 -0
  80. package/src/lib/commander/index.test.js +86 -0
  81. package/src/lib/commander/options.js +195 -0
  82. package/src/lib/commander/options.test.js +109 -0
  83. package/src/lib/conf/README.md +84 -73
  84. package/src/lib/conf/config.js +5 -3
  85. package/src/lib/conf/config.test.js +281 -0
  86. package/src/lib/conf/defaults.js +44 -0
  87. package/src/lib/conf/format.js +60 -0
  88. package/src/lib/conf/format.test.js +106 -0
  89. package/src/lib/conf/helpers.js +91 -0
  90. package/src/lib/conf/helpers.test.js +136 -0
  91. package/src/lib/conf/index.js +22 -0
  92. package/src/lib/conf/schema.js +53 -8
  93. package/src/lib/epub/README.md +133 -2
  94. package/src/lib/epub/engines.js +46 -0
  95. package/src/lib/epub/epub.js +36 -11
  96. package/src/lib/epub/index.js +111 -21
  97. package/src/lib/epub/index.test.js +518 -0
  98. package/src/lib/epub/pandoc.js +33 -4
  99. package/src/lib/epub/pandoc.test.js +122 -0
  100. package/src/lib/epub/schema.js +21 -0
  101. package/src/lib/error/README.md +170 -0
  102. package/src/lib/error/handler.js +107 -0
  103. package/src/lib/git/README.md +151 -2
  104. package/src/lib/git/index.js +217 -13
  105. package/src/lib/git/index.spec.js +80 -0
  106. package/src/lib/git/index.test.js +453 -0
  107. package/src/lib/installer/index.js +309 -0
  108. package/src/lib/installer/index.spec.js +83 -0
  109. package/src/lib/installer/index.test.js +545 -0
  110. package/src/lib/logger/README.md +424 -0
  111. package/src/lib/logger/debug.js +90 -0
  112. package/src/lib/logger/debug.spec.js +130 -0
  113. package/src/lib/logger/index.js +228 -0
  114. package/src/lib/logger/index.spec.js +131 -0
  115. package/src/lib/logger/index.test.js +477 -0
  116. package/src/lib/npm/README.md +127 -0
  117. package/src/lib/npm/index.js +198 -0
  118. package/src/lib/npm/index.spec.js +60 -0
  119. package/src/lib/npm/index.test.js +355 -0
  120. package/src/lib/pdf/README.md +131 -0
  121. package/src/lib/pdf/engines.js +46 -0
  122. package/src/lib/pdf/index.js +124 -21
  123. package/src/lib/pdf/index.test.js +708 -0
  124. package/src/lib/pdf/paged.js +100 -52
  125. package/src/lib/pdf/paged.test.js +366 -0
  126. package/src/lib/pdf/prince.js +115 -37
  127. package/src/lib/pdf/prince.test.js +202 -0
  128. package/src/lib/pdf/schema.js +21 -0
  129. package/src/lib/pdf/split.js +61 -33
  130. package/src/lib/pdf/split.test.js +445 -0
  131. package/src/lib/process/manager.js +110 -0
  132. package/src/lib/process/manager.test.js +55 -0
  133. package/src/lib/project/build.js +143 -0
  134. package/src/lib/project/build.test.js +253 -0
  135. package/src/lib/project/config.js +48 -0
  136. package/src/lib/project/config.test.js +134 -0
  137. package/src/{helpers/is-quire.js → lib/project/detect.js} +5 -3
  138. package/src/lib/project/detect.test.js +157 -0
  139. package/src/lib/project/index.js +40 -0
  140. package/src/lib/project/paths.js +224 -0
  141. package/src/lib/project/version.js +110 -0
  142. package/src/lib/project/version.test.js +350 -0
  143. package/src/lib/reporter/README.md +211 -2
  144. package/src/lib/reporter/index.js +512 -0
  145. package/src/lib/reporter/index.test.js +593 -0
  146. package/src/main.js +134 -47
  147. package/src/main.spec.js +61 -0
  148. package/src/main.test.js +347 -0
  149. package/src/validators/utils.js +2 -1
  150. package/src/commands/conf.js +0 -43
  151. package/src/commands/version.js +0 -43
  152. package/src/lib/11ty/paths.js +0 -103
  153. package/src/lib/i18n/README.md +0 -3
  154. package/src/lib/i18n/config.js +0 -53
  155. package/src/lib/i18n/index.js +0 -43
  156. package/src/lib/i18n/localeService.js +0 -58
  157. package/src/lib/quire/README.md +0 -19
  158. package/src/lib/quire/index.js +0 -350
@@ -5,33 +5,53 @@ import { fileURLToPath } from 'node:url'
5
5
  import { splitPdf } from './split.js'
6
6
  import fs from 'fs-extra'
7
7
 
8
- import which from '#helpers/which.js'
8
+ import processManager from '#lib/process/manager.js'
9
+ import reporter from '#lib/reporter/index.js'
10
+ import { PdfGenerationError } from '#src/errors/index.js'
11
+ import createDebug from '#debug'
12
+ import ENGINES from './engines.js'
9
13
 
10
14
  const __filename = fileURLToPath(import.meta.url)
11
15
  const __dirname = path.dirname(__filename)
12
16
 
17
+ const debug = createDebug('lib:pdf:prince')
18
+
19
+ /** Re-export engine metadata from central registry */
20
+ export const metadata = ENGINES.prince
21
+
13
22
  /**
14
23
  * A façade module for interacting with the Prince CLI.
15
24
  * @see https://www.princexml.com/doc/command-line/
25
+ *
26
+ * Prince availability is checked by the parent façade (lib/pdf/index.js)
27
+ * before this module is loaded, so we can assume prince is in PATH.
28
+ *
29
+ * @param {string} publicationInput - Path to the publication HTML file
30
+ * @param {string} coversInput - Path to the covers HTML file
31
+ * @param {string} pdfPath - Path where the PDF should be written
32
+ * @param {Object} [options={}] - Generation options
33
+ * @returns {Promise<string>} Path to the generated PDF file
16
34
  */
17
- export default async (publicationInput, coversInput, output, options = {}) => {
18
- which('prince')
35
+ export default async (publicationInput, coversInput, pdfPath, options = {}) => {
36
+ debug('input: %s', publicationInput)
37
+ debug('covers: %s', coversInput)
38
+ debug('output: %s', pdfPath)
19
39
 
20
40
  /**
21
41
  * @see https://www.princexml.com/doc/command-line/#options
22
42
  */
23
-
43
+
24
44
  const { pdfConfig } = options
25
45
 
26
46
  // These options run once to get the map pages to essays
27
47
  const pageMapOptions = [
28
48
  `--script=${ path.join(__dirname, 'princePlugin.js') }`,
29
- `--output=${output}`,
49
+ `--output=${pdfPath}`,
30
50
  ]
31
51
 
32
52
  // These options are for the actual user-facing PDF
33
53
  const cmdOptions = [
34
- `--output=${output}`,
54
+ `--output=${pdfPath}`,
35
55
  `--pdf-profile=PDF/UA-1`,
36
56
  ]
37
57
 
@@ -39,60 +59,118 @@ export default async (publicationInput, coversInput, output, options = {}) => {
39
59
  pageMapOptions.push('--debug')
40
60
  cmdOptions.push('--debug')
41
61
  }
42
-
62
+
43
63
  if (options.verbose) {
44
64
  pageMapOptions.push('--verbose')
45
65
  cmdOptions.push('--verbose')
46
66
  }
47
67
 
48
- const { dir } = path.parse(output)
49
- if (!fs.existsSync(dir)) {
50
- fs.mkdirsSync(dir)
68
+ debug('page map options: %O', pageMapOptions)
69
+ debug('cmd options: %O', cmdOptions)
70
+
71
+ try {
72
+ const { dir } = path.parse(pdfPath)
73
+ if (!fs.existsSync(dir)) {
74
+ fs.mkdirsSync(dir)
75
+ }
76
+ } catch (error) {
77
+ throw new PdfGenerationError('Prince', 'output directory creation', error.message)
51
78
  }
52
-
53
- // Execute the page mapping PDF build
79
+
80
+ // Execute page map extraction (renders PDF with script to extract page info, then discards it)
81
+ debug('analyzing document structure')
82
+ reporter.update('Analyzing document structure...')
54
83
  let pageMap = {}
55
84
  try {
56
- const pageMapOutput = await execa('prince', [...pageMapOptions, publicationInput])
85
+ const pageMapOutput = await execa('prince', [...pageMapOptions, publicationInput], {
86
+ cancelSignal: processManager.signal,
87
+ gracefulCancel: true
88
+ })
57
89
  pageMap = JSON.parse(pageMapOutput.stdout)
58
- } catch (err) {
59
- console.error(`Generating the PDF page map failed with the error ${err.stderr}`)
60
- process.exit(1)
90
+ } catch (error) {
91
+ if (error.isCanceled) {
92
+ debug('page map generation cancelled')
93
+ reporter.warn('PDF generation cancelled')
94
+ return
95
+ }
96
+ throw new PdfGenerationError('Prince', 'page map generation', error.stderr)
61
97
  }
98
+ debug('page map generated: %d entries', Object.keys(pageMap).length)
62
99
 
63
100
  let coversData
64
101
 
65
102
  if (pdfConfig?.pagePDF?.coverPage === true && fs.existsSync(coversInput)) {
103
+ debug('generating cover page map')
104
+ reporter.update('Rendering cover pages...')
105
+ try {
106
+ const coversPageMapOutput = await execa('prince', [...pageMapOptions, coversInput], {
107
+ cancelSignal: processManager.signal,
108
+ gracefulCancel: true
109
+ })
110
+ const coversMap = JSON.parse(coversPageMapOutput.stdout)
111
+
112
+ for (const pageId of Object.keys(coversMap)) {
113
+ if (pageId in pageMap) {
114
+ pageMap[pageId].coverPage = coversMap[pageId].startPage
115
+ }
116
+ }
66
117
 
67
- const coversPageMapOutput = await execa('prince', [...pageMapOptions, coversInput])
68
- const coversMap = JSON.parse(coversPageMapOutput.stdout)
69
-
70
- for (const pageId of Object.keys(coversMap)) {
71
- if (pageId in pageMap) {
72
- pageMap[pageId].coverPage = coversMap[pageId].startPage
118
+ coversData = fs.readFileSync(pdfPath, null)
119
+ } catch (error) {
120
+ if (error.isCanceled) {
121
+ debug('cover page map generation cancelled')
122
+ reporter.warn('PDF generation cancelled')
123
+ return
73
124
  }
125
+ throw new PdfGenerationError('Prince', 'cover page map generation', error.stderr || error.message)
74
126
  }
127
+ debug('cover page map merged')
128
+ }
75
129
 
76
- coversData = fs.readFileSync(output,null)
130
+ debug('printing PDF')
131
+ reporter.update('Rendering PDF...')
77
132
 
133
+ try {
134
+ const { stderr } = await execa('prince', [...cmdOptions, publicationInput], {
135
+ cancelSignal: processManager.signal,
136
+ gracefulCancel: true
137
+ })
138
+ // Prince may emit warnings to stderr even on success (CSS warnings, font substitutions, etc.)
139
+ if (stderr) {
140
+ debug('prince warnings: %s', stderr)
141
+ }
142
+ } catch (error) {
143
+ if (error.isCanceled) {
144
+ debug('PDF printing cancelled')
145
+ reporter.warn('PDF generation cancelled')
146
+ return
147
+ }
148
+ throw new PdfGenerationError('Prince', 'PDF printing', error.stderr)
78
149
  }
150
+ debug('PDF printed')
79
151
 
80
- let stderror,stdout
81
-
152
+ let pdfData
82
153
  try {
83
- ({ stderror, stdout } = await execa('prince', [...cmdOptions, publicationInput]))
84
- } catch (err) {
85
- console.error(`Printing the PDF failed with the error ${err.stderr}`)
86
- process.exit(1)
154
+ pdfData = fs.readFileSync(pdfPath, null)
155
+ } catch (error) {
156
+ throw new PdfGenerationError('Prince', 'PDF file read', error.message)
87
157
  }
88
158
 
89
- const pdfData = fs.readFileSync(output,null)
90
-
91
- let files = await splitPdf(pdfData,coversData,pageMap,pdfConfig)
92
- Object.entries(files).forEach( async ([filePath,pagePdf]) => {
93
- await fs.promises.writeFile(filePath,pagePdf)
94
- .catch((error) => console.error(error))
95
- })
159
+ debug('splitting PDF')
160
+ reporter.update('Writing PDF files...')
161
+ try {
162
+ const files = await splitPdf(pdfData, coversData, pageMap, pdfConfig)
163
+
164
+ debug('writing %d section files', Object.keys(files).length)
165
+ await Promise.all(
166
+ Object.entries(files).map(([filePath, pagePdf]) =>
167
+ fs.promises.writeFile(filePath, pagePdf)
168
+ )
169
+ )
170
+ } catch (error) {
171
+ throw new PdfGenerationError('Prince', 'PDF splitting', error.message)
172
+ }
96
173
 
97
- return { stderror, stdout }
174
+ debug('complete')
175
+ return pdfPath
98
176
  }
@@ -0,0 +1,202 @@
1
+ import esmock from 'esmock'
2
+ import path from 'node:path'
3
+ import sinon from 'sinon'
4
+ import test from 'ava'
5
+
6
+ /**
7
+ * Prince PDF Engine Integration Tests
8
+ *
9
+ * Tests error handling paths that are difficult to trigger in E2E tests.
10
+ * The happy path is covered by E2E tests with real Prince installation.
11
+ */
12
+
13
+ /**
14
+ * Create a cross-platform absolute path for testing
15
+ * @param {...string} segments - Path segments to join
16
+ * @returns {string} Platform-appropriate absolute path
17
+ */
18
+ function testPath(...segments) {
19
+ const root = process.platform === 'win32' ? 'C:\\' : '/'
20
+ return path.join(root, ...segments)
21
+ }
22
+
23
+ test.beforeEach((t) => {
24
+ t.context.sandbox = sinon.createSandbox()
25
+
26
+ // Mock reporter to prevent actual console output
27
+ t.context.mockReporter = {
28
+ update: t.context.sandbox.stub(),
29
+ warn: t.context.sandbox.stub()
30
+ }
31
+
32
+ // Mock processManager
33
+ t.context.mockProcessManager = {
34
+ signal: new AbortController().signal
35
+ }
36
+
37
+ // Mock fs-extra
38
+ t.context.mockFs = {
39
+ existsSync: t.context.sandbox.stub().returns(true),
40
+ mkdirsSync: t.context.sandbox.stub(),
41
+ readFileSync: t.context.sandbox.stub().returns(Buffer.from([1, 2, 3])),
42
+ promises: {
43
+ writeFile: t.context.sandbox.stub().resolves()
44
+ }
45
+ }
46
+ })
47
+
48
+ test.afterEach.always((t) => {
49
+ t.context.sandbox.restore()
50
+ })
51
+
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // Error handling tests
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+
56
+ test('throws PdfGenerationError when page map generation fails', async (t) => {
57
+ const { sandbox, mockReporter, mockProcessManager, mockFs } = t.context
58
+
59
+ const execaError = new Error('Prince failed')
60
+ execaError.stderr = 'error: invalid HTML at line 42'
61
+
62
+ const prince = await esmock('./prince.js', {
63
+ execa: { execa: sandbox.stub().rejects(execaError) },
64
+ 'fs-extra': mockFs,
65
+ '#lib/reporter/index.js': { default: mockReporter },
66
+ '#lib/process/manager.js': { default: mockProcessManager }
67
+ })
68
+
69
+ const error = await t.throwsAsync(() =>
70
+ prince.default(testPath('input.html'), testPath('covers.html'), testPath('output.pdf'), {})
71
+ )
72
+
73
+ t.is(error.code, 'PDF_FAILED')
74
+ t.true(error.message.includes('Prince'))
75
+ t.true(error.message.includes('page map generation'))
76
+ t.true(error.message.includes('invalid HTML at line 42'))
77
+ })
78
+
79
+ test('throws PdfGenerationError when PDF printing fails', async (t) => {
80
+ const { sandbox, mockReporter, mockProcessManager, mockFs } = t.context
81
+
82
+ // First call (page map) succeeds, second call (print) fails
83
+ const execaStub = sandbox.stub()
84
+ execaStub.onCall(0).resolves({ stdout: '{}' }) // page map succeeds
85
+ const printError = new Error('Prince print failed')
86
+ printError.stderr = 'error: out of memory'
87
+ execaStub.onCall(1).rejects(printError)
88
+
89
+ const prince = await esmock('./prince.js', {
90
+ execa: { execa: execaStub },
91
+ 'fs-extra': mockFs,
92
+ '#lib/reporter/index.js': { default: mockReporter },
93
+ '#lib/process/manager.js': { default: mockProcessManager }
94
+ })
95
+
96
+ const error = await t.throwsAsync(() =>
97
+ prince.default(testPath('input.html'), testPath('covers.html'), testPath('output.pdf'), {})
98
+ )
99
+
100
+ t.is(error.code, 'PDF_FAILED')
101
+ t.true(error.message.includes('Prince'))
102
+ t.true(error.message.includes('PDF printing'))
103
+ t.true(error.message.includes('out of memory'))
104
+ })
105
+
106
+ test('throws PdfGenerationError when cover page map generation fails', async (t) => {
107
+ const { sandbox, mockReporter, mockProcessManager, mockFs } = t.context
108
+
109
+ // First call (page map) succeeds, second call (covers) fails
110
+ const execaStub = sandbox.stub()
111
+ execaStub.onCall(0).resolves({ stdout: '{"page-1": {"startPage": 0}}' })
112
+ const coversError = new Error('Prince covers failed')
113
+ coversError.stderr = 'error: covers file corrupt'
114
+ execaStub.onCall(1).rejects(coversError)
115
+
116
+ const prince = await esmock('./prince.js', {
117
+ execa: { execa: execaStub },
118
+ 'fs-extra': mockFs,
119
+ '#lib/reporter/index.js': { default: mockReporter },
120
+ '#lib/process/manager.js': { default: mockProcessManager }
121
+ })
122
+
123
+ // Enable cover page generation
124
+ const options = {
125
+ pdfConfig: { pagePDF: { coverPage: true } }
126
+ }
127
+
128
+ const error = await t.throwsAsync(() =>
129
+ prince.default(testPath('input.html'), testPath('covers.html'), testPath('output.pdf'), options)
130
+ )
131
+
132
+ t.is(error.code, 'PDF_FAILED')
133
+ t.true(error.message.includes('Prince'))
134
+ t.true(error.message.includes('cover page map generation'))
135
+ t.true(error.message.includes('covers file corrupt'))
136
+ })
137
+
138
+ test('warns and returns when page map generation is cancelled', async (t) => {
139
+ const { sandbox, mockReporter, mockProcessManager, mockFs } = t.context
140
+
141
+ const cancelError = new Error('Cancelled')
142
+ cancelError.isCanceled = true
143
+
144
+ const prince = await esmock('./prince.js', {
145
+ execa: { execa: sandbox.stub().rejects(cancelError) },
146
+ 'fs-extra': mockFs,
147
+ '#lib/reporter/index.js': { default: mockReporter },
148
+ '#lib/process/manager.js': { default: mockProcessManager }
149
+ })
150
+
151
+ const result = await prince.default(testPath('input.html'), testPath('covers.html'), testPath('output.pdf'), {})
152
+
153
+ t.is(result, undefined)
154
+ t.true(mockReporter.warn.calledWith('PDF generation cancelled'))
155
+ })
156
+
157
+ test('warns and returns when PDF printing is cancelled', async (t) => {
158
+ const { sandbox, mockReporter, mockProcessManager, mockFs } = t.context
159
+
160
+ const execaStub = sandbox.stub()
161
+ execaStub.onCall(0).resolves({ stdout: '{}' }) // page map succeeds
162
+ const cancelError = new Error('Cancelled')
163
+ cancelError.isCanceled = true
164
+ execaStub.onCall(1).rejects(cancelError)
165
+
166
+ const prince = await esmock('./prince.js', {
167
+ execa: { execa: execaStub },
168
+ 'fs-extra': mockFs,
169
+ '#lib/reporter/index.js': { default: mockReporter },
170
+ '#lib/process/manager.js': { default: mockProcessManager }
171
+ })
172
+
173
+ const result = await prince.default(testPath('input.html'), testPath('covers.html'), testPath('output.pdf'), {})
174
+
175
+ t.is(result, undefined)
176
+ t.true(mockReporter.warn.calledWith('PDF generation cancelled'))
177
+ })
178
+
179
+ test('throws PdfGenerationError when output directory creation fails', async (t) => {
180
+ const { sandbox, mockReporter, mockProcessManager } = t.context
181
+
182
+ const mockFs = {
183
+ existsSync: sandbox.stub().returns(false),
184
+ mkdirsSync: sandbox.stub().throws(new Error('EACCES: permission denied'))
185
+ }
186
+
187
+ const prince = await esmock('./prince.js', {
188
+ execa: { execa: sandbox.stub() },
189
+ 'fs-extra': mockFs,
190
+ '#lib/reporter/index.js': { default: mockReporter },
191
+ '#lib/process/manager.js': { default: mockProcessManager }
192
+ })
193
+
194
+ const error = await t.throwsAsync(() =>
195
+ prince.default(testPath('input.html'), testPath('covers.html'), testPath('restricted', 'output.pdf'), {})
196
+ )
197
+
198
+ t.is(error.code, 'PDF_FAILED')
199
+ t.true(error.message.includes('Prince'))
200
+ t.true(error.message.includes('output directory creation'))
201
+ t.true(error.message.includes('permission denied'))
202
+ })
@@ -0,0 +1,21 @@
1
+ /**
2
+ * PDF engine schema definition
3
+ *
4
+ * This module has NO imports to avoid circular dependencies.
5
+ * It serves as the single source of truth for PDF engine options.
6
+ */
7
+
8
+ /**
9
+ * Supported PDF engines
10
+ * @type {string[]}
11
+ */
12
+ export const ENGINES = ['pagedjs', 'prince']
13
+
14
+ /**
15
+ * Schema definition for pdfEngine config property
16
+ */
17
+ export const schema = {
18
+ type: 'string',
19
+ enum: ENGINES,
20
+ description: 'Default PDF engine to use (pagedjs, prince)'
21
+ }
@@ -1,61 +1,89 @@
1
1
  import path from 'node:path'
2
-
3
2
  import { PDFDocument } from 'pdf-lib'
4
3
  import { paths } from '#lib/11ty/index.js'
5
-
4
+ import { PdfGenerationError } from '#src/errors/index.js'
5
+ import createDebug from '#debug'
6
+
7
+ const debug = createDebug('lib:pdf:split')
8
+
6
9
  /**
7
- * @function splitPdf(file,pageMap) -- sections out individual PDFs from `file` according to `pageMap`
8
- *
10
+ * Split a PDF into sections based on a page map
11
+ *
9
12
  * @param {ArrayBuffer} file - PDF file to split
10
- * @param {Object} pageMap - page map to split PDf by
11
- *
12
- * Creates individual PDFs from by copying `file` (so boxes are already set) and stripping pages out of the range (in reverse order to retain the index sequence)
13
- * Returns a map of file paths to PDF binary data for serialization
13
+ * @param {ArrayBuffer} [coversFile] - Optional covers PDF file
14
+ * @param {Object} pageMap - Map of page IDs to page ranges
15
+ * @param {Object} [pdfConfig] - PDF configuration from project config
16
+ * @returns {Promise<Object>} Map of file paths to PDF binary data
17
+ * @throws {PdfGenerationError} When PDF loading or manipulation fails
14
18
  */
19
+ export async function splitPdf(file, coversFile, pageMap, pdfConfig) {
15
20
 
16
- export async function splitPdf(file,coversFile,pageMap,pdfConfig) {
17
-
18
- if (!pdfConfig) {
21
+ if (!pdfConfig) {
22
+ debug('No pdfConfig provided, PDF splitting skipped.')
19
23
  return {}
20
24
  }
21
25
 
22
26
  const { filename, outputDir } = pdfConfig
23
27
 
24
- const pdfDoc = await PDFDocument.load(file)
28
+ debug('loading main PDF')
29
+ let pdfDoc
30
+ try {
31
+ pdfDoc = await PDFDocument.load(file)
32
+ } catch (error) {
33
+ throw new PdfGenerationError('pdf-lib', 'load main PDF', error.message)
34
+ }
35
+ debug('main PDF loaded: %d pages', pdfDoc.getPageCount())
25
36
 
26
- const coversDoc = (coversFile !== undefined) ? await PDFDocument.load(coversFile) : undefined
37
+ let coversDoc
38
+ if (coversFile !== undefined) {
39
+ debug('loading covers PDF')
40
+ try {
41
+ coversDoc = await PDFDocument.load(coversFile)
42
+ } catch (error) {
43
+ throw new PdfGenerationError('pdf-lib', 'load covers PDF', error.message)
44
+ }
45
+ debug('covers PDF loaded: %d pages', coversDoc.getPageCount())
46
+ }
27
47
 
28
48
  let resultFiles = {}
49
+ const pageEntries = Object.entries(pageMap)
50
+ debug('splitting into %d sections', pageEntries.length)
29
51
 
30
- for ( const [pageId, pageConfig] of Object.entries(pageMap) ) {
52
+ for (const [pageId, pageConfig] of pageEntries) {
31
53
  const { endPage, startPage, coverPage } = pageConfig
32
54
 
33
- // TODO: Set the PDF's sectional doc metadata
34
-
35
- const sectionDoc = await pdfDoc.copy()
55
+ try {
56
+ // TODO: Set the PDF's sectional doc metadata
36
57
 
37
- for (let p=pdfDoc.getPageCount() - 1; p > endPage; --p) {
38
- sectionDoc.removePage(p)
39
- }
40
- for (let q=startPage - 1; q >= 0; --q) {
41
- sectionDoc.removePage(q)
42
- }
58
+ const sectionDoc = await pdfDoc.copy()
43
59
 
44
- if (coversDoc && coverPage !== undefined && coverPage >= 0) {
45
- // NB: `copyPages()` sets page sizing + other metadata on the way into the target PDF
46
- const cover = await sectionDoc.copyPages(coversDoc,[coverPage])
47
- sectionDoc.insertPage(0,cover[0])
48
- }
60
+ for (let p = pdfDoc.getPageCount() - 1; p > endPage; --p) {
61
+ sectionDoc.removePage(p)
62
+ }
63
+ for (let q = startPage - 1; q >= 0; --q) {
64
+ sectionDoc.removePage(q)
65
+ }
66
+
67
+ if (coversDoc && coverPage !== undefined && coverPage >= 0) {
68
+ // copyPages sets page sizing and other metadata on the target PDF
69
+ const cover = await sectionDoc.copyPages(coversDoc, [coverPage])
70
+ sectionDoc.insertPage(0, cover[0])
71
+ }
49
72
 
50
- const section = await sectionDoc.save()
73
+ const section = await sectionDoc.save()
51
74
 
52
- const sectionId = pageId.replace(/^page-/g,'')
53
- const sectionFn = `${filename}-${sectionId}.pdf`
54
- const sectionFp = path.join( paths.output, outputDir, sectionFn )
75
+ const sectionId = pageId.replace(/^page-/g, '')
76
+ const sectionFn = `${filename}-${sectionId}.pdf`
77
+ const sectionFp = path.join(paths.getOutputDir(), outputDir, sectionFn)
55
78
 
56
- resultFiles[sectionFp] = section
79
+ resultFiles[sectionFp] = section
80
+ debug('extracted section %s (pages %d-%d)', sectionId, startPage, endPage)
81
+ } catch (error) {
82
+ throw new PdfGenerationError('pdf-lib', `extract section '${pageId}'`, error.message)
83
+ }
57
84
  }
58
85
 
86
+ debug('split complete: %d files', Object.keys(resultFiles).length)
59
87
  return resultFiles
60
88
  }
61
89