@thegetty/quire-cli 1.0.0-rc.34 → 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.
- package/CHANGELOG.md +36 -0
- package/README.md +9 -0
- package/bin/cli.js +19 -1
- package/package.json +21 -8
- package/schemas/config.schema.json +194 -0
- package/schemas/figures.schema.json +56 -0
- package/schemas/layout.schema.json +5 -0
- package/schemas/objects.schema.json +62 -0
- package/schemas/publication.schema.json +140 -0
- package/schemas/references.schema.json +29 -0
- package/src/Command.js +26 -6
- package/src/Command.spec.js +99 -0
- package/src/commands/README.md +213 -122
- package/src/commands/build.js +46 -37
- package/src/commands/build.spec.js +113 -0
- package/src/commands/build.test.js +402 -0
- package/src/commands/clean.js +25 -19
- package/src/commands/clean.spec.js +108 -0
- package/src/commands/clean.test.js +260 -0
- package/src/commands/config.js +251 -0
- package/src/commands/config.spec.js +107 -0
- package/src/commands/config.test.js +715 -0
- package/src/commands/create.js +42 -14
- package/src/commands/create.spec.js +112 -0
- package/src/commands/create.test.js +415 -0
- package/src/commands/epub.js +59 -30
- package/src/commands/epub.spec.js +114 -0
- package/src/commands/epub.test.js +503 -0
- package/src/commands/index.js +9 -2
- package/src/commands/info.js +18 -24
- package/src/commands/info.spec.js +64 -0
- package/src/commands/info.test.js +415 -0
- package/src/commands/pdf.js +58 -84
- package/src/commands/pdf.spec.js +114 -0
- package/src/commands/pdf.test.js +464 -0
- package/src/commands/preview.js +26 -31
- package/src/commands/preview.spec.js +97 -0
- package/src/commands/preview.test.js +250 -0
- package/src/commands/use.js +56 -0
- package/src/commands/use.spec.js +61 -0
- package/src/commands/use.test.js +280 -0
- package/src/commands/validate.js +31 -19
- package/src/commands/validate.spec.js +82 -0
- package/src/commands/validate.test.js +234 -0
- package/src/commands/workflows.js +70 -0
- package/src/errors/build/build-failed-error.js +19 -0
- package/src/errors/build/config-field-missing-error.js +20 -0
- package/src/errors/build/config-file-not-found-error.js +20 -0
- package/src/errors/build/index.js +11 -0
- package/src/errors/index.js +48 -0
- package/src/errors/install/dependency-install-error.js +19 -0
- package/src/errors/install/directory-not-empty-error.js +25 -0
- package/src/errors/install/index.js +12 -0
- package/src/errors/install/invalid-path-error.js +31 -0
- package/src/errors/install/invalid-starter-error.js +27 -0
- package/src/errors/install/version-not-found-error.js +35 -0
- package/src/errors/output/epub-generation-error.js +19 -0
- package/src/errors/output/index.js +14 -0
- package/src/errors/output/invalid-epub-library-error.js +20 -0
- package/src/errors/output/invalid-pdf-library-error.js +20 -0
- package/src/errors/output/missing-build-output-error.js +21 -0
- package/src/errors/output/pdf-generation-error.js +24 -0
- package/src/errors/output/tool-not-found-error.js +37 -0
- package/src/errors/project/index.js +10 -0
- package/src/errors/project/not-in-project-error.js +20 -0
- package/src/errors/project/project-create-error.js +21 -0
- package/src/errors/quire-error.js +27 -0
- package/src/errors/validation/validation-error.js +20 -11
- package/src/helpers/clean.js +1 -1
- package/src/helpers/docs-url.js +32 -0
- package/src/helpers/test-cwd.js +5 -6
- package/src/helpers/test-cwd.test.js +192 -0
- package/src/helpers/which.js +10 -4
- package/src/lib/11ty/README.md +135 -19
- package/src/lib/11ty/api.js +176 -93
- package/src/lib/11ty/cli.js +77 -35
- package/src/lib/11ty/index.js +64 -5
- package/src/lib/11ty/index.test.js +655 -0
- package/src/lib/README.md +275 -0
- package/src/lib/commander/index.js +100 -0
- package/src/lib/commander/index.test.js +86 -0
- package/src/lib/commander/options.js +195 -0
- package/src/lib/commander/options.test.js +109 -0
- package/src/lib/conf/README.md +84 -73
- package/src/lib/conf/config.js +5 -3
- package/src/lib/conf/config.test.js +281 -0
- package/src/lib/conf/defaults.js +44 -0
- package/src/lib/conf/format.js +60 -0
- package/src/lib/conf/format.test.js +106 -0
- package/src/lib/conf/helpers.js +91 -0
- package/src/lib/conf/helpers.test.js +136 -0
- package/src/lib/conf/index.js +22 -0
- package/src/lib/conf/schema.js +53 -8
- package/src/lib/epub/README.md +133 -2
- package/src/lib/epub/engines.js +46 -0
- package/src/lib/epub/epub.js +36 -11
- package/src/lib/epub/index.js +111 -21
- package/src/lib/epub/index.test.js +518 -0
- package/src/lib/epub/pandoc.js +33 -4
- package/src/lib/epub/pandoc.test.js +122 -0
- package/src/lib/epub/schema.js +21 -0
- package/src/lib/error/README.md +170 -0
- package/src/lib/error/handler.js +107 -0
- package/src/lib/git/README.md +151 -2
- package/src/lib/git/index.js +217 -13
- package/src/lib/git/index.spec.js +80 -0
- package/src/lib/git/index.test.js +453 -0
- package/src/lib/installer/index.js +309 -0
- package/src/lib/installer/index.spec.js +83 -0
- package/src/lib/installer/index.test.js +545 -0
- package/src/lib/logger/README.md +424 -0
- package/src/lib/logger/debug.js +90 -0
- package/src/lib/logger/debug.spec.js +130 -0
- package/src/lib/logger/index.js +228 -0
- package/src/lib/logger/index.spec.js +131 -0
- package/src/lib/logger/index.test.js +477 -0
- package/src/lib/npm/README.md +127 -0
- package/src/lib/npm/index.js +198 -0
- package/src/lib/npm/index.spec.js +60 -0
- package/src/lib/npm/index.test.js +355 -0
- package/src/lib/pdf/README.md +131 -0
- package/src/lib/pdf/engines.js +46 -0
- package/src/lib/pdf/index.js +124 -21
- package/src/lib/pdf/index.test.js +708 -0
- package/src/lib/pdf/paged.js +100 -52
- package/src/lib/pdf/paged.test.js +366 -0
- package/src/lib/pdf/prince.js +115 -37
- package/src/lib/pdf/prince.test.js +202 -0
- package/src/lib/pdf/schema.js +21 -0
- package/src/lib/pdf/split.js +61 -33
- package/src/lib/pdf/split.test.js +445 -0
- package/src/lib/process/manager.js +110 -0
- package/src/lib/process/manager.test.js +55 -0
- package/src/lib/project/build.js +143 -0
- package/src/lib/project/build.test.js +253 -0
- package/src/lib/project/config.js +48 -0
- package/src/lib/project/config.test.js +134 -0
- package/src/{helpers/is-quire.js → lib/project/detect.js} +5 -3
- package/src/lib/project/detect.test.js +157 -0
- package/src/lib/project/index.js +40 -0
- package/src/lib/project/paths.js +224 -0
- package/src/lib/project/version.js +110 -0
- package/src/lib/project/version.test.js +350 -0
- package/src/lib/reporter/README.md +211 -2
- package/src/lib/reporter/index.js +512 -0
- package/src/lib/reporter/index.test.js +593 -0
- package/src/main.js +134 -47
- package/src/main.spec.js +61 -0
- package/src/main.test.js +347 -0
- package/src/validators/utils.js +2 -1
- package/src/commands/conf.js +0 -43
- package/src/commands/version.js +0 -43
- package/src/lib/11ty/paths.js +0 -103
- package/src/lib/i18n/README.md +0 -3
- package/src/lib/i18n/config.js +0 -53
- package/src/lib/i18n/index.js +0 -43
- package/src/lib/i18n/localeService.js +0 -58
- package/src/lib/quire/README.md +0 -19
- package/src/lib/quire/index.js +0 -350
package/src/lib/pdf/prince.js
CHANGED
|
@@ -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
|
|
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,
|
|
18
|
-
|
|
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=${
|
|
49
|
+
`--output=${pdfPath}`,
|
|
30
50
|
]
|
|
31
51
|
|
|
32
52
|
// These options are for the actual user-facing PDF
|
|
33
53
|
const cmdOptions = [
|
|
34
|
-
`--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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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 (
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
152
|
+
let pdfData
|
|
82
153
|
try {
|
|
83
|
-
|
|
84
|
-
} catch (
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/pdf/split.js
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
10
|
+
* Split a PDF into sections based on a page map
|
|
11
|
+
*
|
|
9
12
|
* @param {ArrayBuffer} file - PDF file to split
|
|
10
|
-
* @param {
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
52
|
+
for (const [pageId, pageConfig] of pageEntries) {
|
|
31
53
|
const { endPage, startPage, coverPage } = pageConfig
|
|
32
54
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const sectionDoc = await pdfDoc.copy()
|
|
55
|
+
try {
|
|
56
|
+
// TODO: Set the PDF's sectional doc metadata
|
|
36
57
|
|
|
37
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
73
|
+
const section = await sectionDoc.save()
|
|
51
74
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|