💄 display chapter title in exported project PDF
This commit is contained in:
43
package-lock.json
generated
43
package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -1708,6 +1709,24 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -5276,6 +5295,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@@ -5320,6 +5345,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pdf-parse": {
|
"node_modules/pdf-parse": {
|
||||||
"version": "2.4.5",
|
"version": "2.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||||
@@ -6110,6 +6147,12 @@
|
|||||||
"utf8-byte-length": "^1.0.1"
|
"utf8-byte-length": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
@@ -290,11 +290,16 @@ export function registerIpcHandlers(): void {
|
|||||||
if (docs.length === 0) return
|
if (docs.length === 0) return
|
||||||
|
|
||||||
const { marked } = await import('marked')
|
const { marked } = await import('marked')
|
||||||
|
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib')
|
||||||
const normalize = (s: string) => s.replace(/\r\n/g, '\n').replace(/\n(?!\n)/g, '\n\n')
|
const normalize = (s: string) => s.replace(/\r\n/g, '\n').replace(/\n(?!\n)/g, '\n\n')
|
||||||
|
|
||||||
let bodyHtml = ''
|
const projectTitle = projectName.toUpperCase()
|
||||||
|
|
||||||
|
// Build an ordered list of sections: part-divider pages + individual chapters.
|
||||||
|
// Each section becomes its own PDF so its title can appear in the running header.
|
||||||
|
interface Section { title: string; bodyHtml: string }
|
||||||
|
const sections: Section[] = []
|
||||||
let currentPart: string | null = null
|
let currentPart: string | null = null
|
||||||
let needsPageBreak = false
|
|
||||||
|
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
const slashIdx = doc.relativePath.indexOf('/')
|
const slashIdx = doc.relativePath.indexOf('/')
|
||||||
@@ -303,66 +308,98 @@ export function registerIpcHandlers(): void {
|
|||||||
if (partName !== null && partName !== currentPart) {
|
if (partName !== null && partName !== currentPart) {
|
||||||
currentPart = partName
|
currentPart = partName
|
||||||
const safe = partName.replace(/&/g, '&').replace(/</g, '<')
|
const safe = partName.replace(/&/g, '&').replace(/</g, '<')
|
||||||
bodyHtml += `<div class="page-break part-page"><div class="part-title">${safe}</div></div>`
|
sections.push({
|
||||||
needsPageBreak = false
|
title: partName,
|
||||||
|
bodyHtml: `<div class="part-page"><div class="part-title">${safe}</div></div>`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const chapterTitle = doc.relativePath.split('/').pop() ?? doc.relativePath
|
const chapterTitle = doc.relativePath.split('/').pop()?.replace(/\.md$/, '') ?? doc.relativePath
|
||||||
const safeChapterTitle = chapterTitle.replace(/&/g, '&').replace(/</g, '<')
|
const safeChapterTitle = chapterTitle.replace(/&/g, '&').replace(/</g, '<')
|
||||||
const contentHtml = await marked(normalize(doc.content))
|
const contentHtml = await marked(normalize(doc.content))
|
||||||
const cls = needsPageBreak ? 'page-break chapter' : 'chapter'
|
sections.push({
|
||||||
bodyHtml += `<div class="${cls}"><h2>${safeChapterTitle}</h2>${contentHtml}</div>`
|
title: chapterTitle,
|
||||||
needsPageBreak = true
|
bodyHtml: `<div class="chapter"><h2>${safeChapterTitle}</h2>${contentHtml}</div>`
|
||||||
}
|
|
||||||
|
|
||||||
const safeTitle = projectName.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
||||||
const headerTitle = projectName.toUpperCase().replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>${safeTitle}</title>
|
|
||||||
<style>
|
|
||||||
@page { size: letter; margin: 1in; }
|
|
||||||
body { font-family: "Courier New", Courier, monospace; font-size: 12pt; line-height: 2; color: #000; margin: 0; padding: 0; }
|
|
||||||
h1, h2, h3 { font-weight: normal; font-size: 12pt; text-align: center; text-transform: uppercase; margin: 0; line-height: 2; }
|
|
||||||
p { margin: 0; text-indent: 0.5in; text-align: left; }
|
|
||||||
h1 + p, h2 + p, h3 + p, hr + p, .chapter > p:first-child { text-indent: 0; }
|
|
||||||
hr { border: none; margin: 0; height: 2em; text-align: center; }
|
|
||||||
hr::after { content: "#"; display: block; text-align: center; line-height: 2; }
|
|
||||||
em { font-style: italic; }
|
|
||||||
strong { font-weight: bold; }
|
|
||||||
blockquote { margin: 0 0 0 0.5in; }
|
|
||||||
ul, ol { margin: 0 0 0 0.5in; }
|
|
||||||
li { margin: 0; }
|
|
||||||
.page-break { page-break-before: always; }
|
|
||||||
.chapter { padding-top: 2.5in; }
|
|
||||||
.chapter:first-child { padding-top: 0; }
|
|
||||||
.part-page { padding-top: 3.5in; text-align: center; }
|
|
||||||
.part-title { font-family: "Courier New", Courier, monospace; font-size: 12pt; text-transform: uppercase; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>${bodyHtml}</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
const tempPath = join(tmpdir(), `hohoff-project-export-${Date.now()}.html`)
|
|
||||||
writeFileSync(tempPath, html, 'utf-8')
|
|
||||||
|
|
||||||
const offscreen = new BrowserWindow({ show: false, webPreferences: { sandbox: false, contextIsolation: true } })
|
|
||||||
try {
|
|
||||||
await offscreen.loadFile(tempPath)
|
|
||||||
const pdfBuffer = await offscreen.webContents.printToPDF({
|
|
||||||
pageSize: 'Letter',
|
|
||||||
displayHeaderFooter: true,
|
|
||||||
headerTemplate: `<div style="font-size:11pt;font-family:'Courier New',Courier,monospace;width:100%;text-align:right;padding-right:72pt;">${headerTitle} / <span class="pageNumber"></span></div>`,
|
|
||||||
footerTemplate: '<div></div>',
|
|
||||||
margins: { marginType: 'custom', top: 1.25, bottom: 1.0, left: 1.0, right: 1.0 }
|
|
||||||
})
|
})
|
||||||
writeFileSync(result.filePath, pdfBuffer)
|
|
||||||
} finally {
|
|
||||||
offscreen.destroy()
|
|
||||||
try { unlinkSync(tempPath) } catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSS shared by every section — no page-break rules needed since each section
|
||||||
|
// is its own HTML document printed independently.
|
||||||
|
const pageCSS = `
|
||||||
|
@page { size: letter; }
|
||||||
|
body { font-family: "Courier New", Courier, monospace; font-size: 12pt; line-height: 2; color: #000; margin: 0; padding: 0; }
|
||||||
|
h1, h2, h3 { font-weight: normal; font-size: 12pt; text-align: center; text-transform: uppercase; margin: 0; line-height: 2; }
|
||||||
|
p { margin: 0; text-indent: 0.5in; text-align: left; }
|
||||||
|
h1 + p, h2 + p, h3 + p, hr + p, .chapter > p:first-child { text-indent: 0; }
|
||||||
|
hr { border: none; margin: 0; height: 2em; text-align: center; }
|
||||||
|
hr::after { content: "#"; display: block; text-align: center; line-height: 2; }
|
||||||
|
em { font-style: italic; }
|
||||||
|
strong { font-weight: bold; }
|
||||||
|
blockquote { margin: 0 0 0 0.5in; }
|
||||||
|
ul, ol { margin: 0 0 0 0.5in; }
|
||||||
|
li { margin: 0; }
|
||||||
|
.chapter { padding-top: 2.5in; }
|
||||||
|
.part-page { padding-top: 3.5in; text-align: center; }
|
||||||
|
.part-title { font-family: "Courier New", Courier, monospace; font-size: 12pt; text-transform: uppercase; }
|
||||||
|
`
|
||||||
|
|
||||||
|
// Print each section to its own PDF buffer (no built-in header — we'll draw
|
||||||
|
// it ourselves with pdf-lib so the chapter title can vary per section).
|
||||||
|
const sectionPdfs: { title: string; buffer: Uint8Array }[] = []
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${pageCSS}</style></head><body>${section.bodyHtml}</body></html>`
|
||||||
|
const tempPath = join(tmpdir(), `hohoff-section-${Date.now()}-${Math.round(Math.random() * 1e9)}.html`)
|
||||||
|
writeFileSync(tempPath, html, 'utf-8')
|
||||||
|
const offscreen = new BrowserWindow({ show: false, webPreferences: { sandbox: false, contextIsolation: true } })
|
||||||
|
try {
|
||||||
|
await offscreen.loadFile(tempPath)
|
||||||
|
const buf = await offscreen.webContents.printToPDF({
|
||||||
|
pageSize: 'Letter',
|
||||||
|
displayHeaderFooter: false,
|
||||||
|
margins: { marginType: 'custom', top: 1.0, bottom: 1.0, left: 1.0, right: 1.0 }
|
||||||
|
})
|
||||||
|
sectionPdfs.push({ title: section.title, buffer: buf })
|
||||||
|
} finally {
|
||||||
|
offscreen.destroy()
|
||||||
|
try { unlinkSync(tempPath) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all section PDFs into one document, tracking which section each page
|
||||||
|
// belongs to so we can stamp the correct running header on it.
|
||||||
|
const mergedPdf = await PDFDocument.create()
|
||||||
|
const courier = await mergedPdf.embedFont(StandardFonts.Courier)
|
||||||
|
const pageOwners: string[] = [] // parallel array: pageOwners[i] = section title for page i
|
||||||
|
|
||||||
|
for (const { title, buffer } of sectionPdfs) {
|
||||||
|
const srcPdf = await PDFDocument.load(buffer)
|
||||||
|
const copied = await mergedPdf.copyPages(srcPdf, srcPdf.getPageIndices())
|
||||||
|
for (const page of copied) {
|
||||||
|
mergedPdf.addPage(page)
|
||||||
|
pageOwners.push(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the running header on every page: PROJECT TITLE / CHAPTER TITLE / absolute page #
|
||||||
|
const allPages = mergedPdf.getPages()
|
||||||
|
for (let i = 0; i < allPages.length; i++) {
|
||||||
|
const page = allPages[i]
|
||||||
|
const { width, height } = page.getSize()
|
||||||
|
const chapterUpper = pageOwners[i].toUpperCase()
|
||||||
|
const headerText = `${projectTitle} / ${chapterUpper} / ${i + 1}`
|
||||||
|
const fontSize = 11
|
||||||
|
const textWidth = courier.widthOfTextAtSize(headerText, fontSize)
|
||||||
|
page.drawText(headerText, {
|
||||||
|
x: width - 72 - textWidth, // 1 in from right edge (72pt = 1in)
|
||||||
|
y: height - 36, // 0.5 in from top edge (36pt = 0.5in)
|
||||||
|
size: fontSize,
|
||||||
|
font: courier,
|
||||||
|
color: rgb(0, 0, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await mergedPdf.save()
|
||||||
|
writeFileSync(result.filePath, pdfBytes)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user