standard manuscript info and cover page

This commit is contained in:
2026-05-27 18:53:50 +10:00
parent 97fd1321b1
commit aa3f6b30c9
6 changed files with 247 additions and 18 deletions

View File

@@ -8,6 +8,12 @@ export interface GlobalConfig {
projectTitle?: string projectTitle?: string
fontSize?: number fontSize?: number
theme?: 'dark' | 'light' theme?: 'dark' | 'light'
// Manuscript metadata
authorName?: string
penName?: string
authorAddress?: string
authorEmail?: string
authorPhone?: string
} }
const CONFIG_DIR = join(homedir(), '.hohoff') const CONFIG_DIR = join(homedir(), '.hohoff')

View File

@@ -312,13 +312,89 @@ export function registerIpcHandlers(): void {
const { marked } = await import('marked') const { marked } = await import('marked')
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib') 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')
const esc = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const projectTitle = projectName.toUpperCase() const projectTitle = projectName.toUpperCase()
// Build an ordered list of sections: part-divider pages + individual chapters. // ── Cover page ──────────────────────────────────────────────────────────────
const cfg = readGlobalConfig()
const authorLegal = cfg.authorName?.trim() ?? ''
const authorByline = cfg.penName?.trim() || authorLegal
const authorAddr = cfg.authorAddress?.trim() ?? ''
const authorEmail = cfg.authorEmail?.trim() ?? ''
const authorPhone = cfg.authorPhone?.trim() ?? ''
// Count words from docs already in memory, rounded to nearest 1,000
const totalWords = docs.reduce((sum, doc) => {
return sum + (doc.content.trim() === '' ? 0 : doc.content.trim().split(/\s+/).length)
}, 0)
const roundTo = totalWords >= 5000 ? 1000 : 100
const rounded = Math.round(totalWords / roundTo) * roundTo
const wordCountText = `~${rounded.toLocaleString('en-US')} words`
// Author contact block (skip empty lines)
const contactLines = [
authorLegal,
...authorAddr.split('\n').map(l => l.trim()).filter(Boolean),
authorPhone,
authorEmail,
].filter(Boolean)
const coverCSS = `
@page { size: letter; }
body {
font-family: "Courier New", Courier, monospace;
font-size: 12pt;
line-height: 1.5;
color: #000;
margin: 0;
padding: 0;
}
.cover-page {
display: flex;
flex-direction: column;
height: 9in;
}
.cover-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.cover-contact { line-height: 1.5; }
.cover-wordcount { text-align: right; line-height: 1.5; }
.cover-title-block {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
line-height: 2;
}
.cover-title { font-size: 12pt; text-transform: uppercase; margin: 0; }
.cover-by { margin: 0; }
.cover-byline{ margin: 0; }
`
const coverBodyHtml = `
<div class="cover-page">
<div class="cover-top">
<div class="cover-contact">${contactLines.map(esc).join('<br>')}</div>
<div class="cover-wordcount">${esc(wordCountText)}</div>
</div>
<div class="cover-title-block">
<p class="cover-title">${esc(projectName)}</p>
${authorByline ? `<p class="cover-by">by</p><p class="cover-byline">${esc(authorByline)}</p>` : ''}
</div>
</div>`
// Build an ordered list of sections: cover + part-divider pages + individual chapters.
// Each section becomes its own PDF so its title can appear in the running header. // Each section becomes its own PDF so its title can appear in the running header.
interface Section { title: string; bodyHtml: string } // isCover=true sections receive no running header (standard manuscript practice).
const sections: Section[] = [] interface Section { title: string; bodyHtml: string; css?: string; isCover?: boolean }
const sections: Section[] = [
{ title: '', bodyHtml: coverBodyHtml, css: coverCSS, isCover: true }
]
let currentPart: string | null = null let currentPart: string | null = null
for (const doc of docs) { for (const doc of docs) {
@@ -343,8 +419,7 @@ export function registerIpcHandlers(): void {
}) })
} }
// CSS shared by every section — no page-break rules needed since each section // CSS shared by chapter/part sectionscover page uses its own CSS (set above).
// is its own HTML document printed independently.
const pageCSS = ` const pageCSS = `
@page { size: letter; } @page { size: letter; }
body { font-family: "Courier New", Courier, monospace; font-size: 12pt; line-height: 2; color: #000; margin: 0; padding: 0; } body { font-family: "Courier New", Courier, monospace; font-size: 12pt; line-height: 2; color: #000; margin: 0; padding: 0; }
@@ -363,12 +438,14 @@ export function registerIpcHandlers(): void {
.part-title { font-family: "Courier New", Courier, monospace; font-size: 12pt; text-transform: uppercase; } .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 // Print each section to its own PDF buffer. Cover page uses its own CSS;
// it ourselves with pdf-lib so the chapter title can vary per section). // chapters/parts use the shared pageCSS. No built-in browser header/footer —
const sectionPdfs: { title: string; buffer: Uint8Array }[] = [] // we stamp running headers ourselves with pdf-lib so the title varies per section.
const sectionPdfs: { title: string; buffer: Uint8Array; isCover: boolean }[] = []
for (const section of sections) { 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 css = section.css ?? pageCSS
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${css}</style></head><body>${section.bodyHtml}</body></html>`
const tempPath = join(tmpdir(), `hohoff-section-${Date.now()}-${Math.round(Math.random() * 1e9)}.html`) const tempPath = join(tmpdir(), `hohoff-section-${Date.now()}-${Math.round(Math.random() * 1e9)}.html`)
writeFileSync(tempPath, html, 'utf-8') writeFileSync(tempPath, html, 'utf-8')
const offscreen = new BrowserWindow({ show: false, webPreferences: { sandbox: false, contextIsolation: true } }) const offscreen = new BrowserWindow({ show: false, webPreferences: { sandbox: false, contextIsolation: true } })
@@ -379,7 +456,7 @@ export function registerIpcHandlers(): void {
displayHeaderFooter: false, displayHeaderFooter: false,
margins: { marginType: 'custom', top: 1.0, bottom: 1.0, left: 1.0, right: 1.0 } margins: { marginType: 'custom', top: 1.0, bottom: 1.0, left: 1.0, right: 1.0 }
}) })
sectionPdfs.push({ title: section.title, buffer: buf }) sectionPdfs.push({ title: section.title, buffer: buf, isCover: section.isCover ?? false })
} finally { } finally {
offscreen.destroy() offscreen.destroy()
try { unlinkSync(tempPath) } catch { /* ignore */ } try { unlinkSync(tempPath) } catch { /* ignore */ }
@@ -390,24 +467,29 @@ export function registerIpcHandlers(): void {
// belongs to so we can stamp the correct running header on it. // belongs to so we can stamp the correct running header on it.
const mergedPdf = await PDFDocument.create() const mergedPdf = await PDFDocument.create()
const courier = await mergedPdf.embedFont(StandardFonts.Courier) const courier = await mergedPdf.embedFont(StandardFonts.Courier)
const pageOwners: string[] = [] // parallel array: pageOwners[i] = section title for page i // pageOwners[i] tracks the section title and whether the page is a cover page
const pageOwners: { title: string; isCover: boolean }[] = []
for (const { title, buffer } of sectionPdfs) { for (const { title, buffer, isCover } of sectionPdfs) {
const srcPdf = await PDFDocument.load(buffer) const srcPdf = await PDFDocument.load(buffer)
const copied = await mergedPdf.copyPages(srcPdf, srcPdf.getPageIndices()) const copied = await mergedPdf.copyPages(srcPdf, srcPdf.getPageIndices())
for (const page of copied) { for (const page of copied) {
mergedPdf.addPage(page) mergedPdf.addPage(page)
pageOwners.push(title) pageOwners.push({ title, isCover })
} }
} }
// Draw the running header on every page: PROJECT TITLE / CHAPTER TITLE / absolute page # // Stamp the running header on body pages only (cover page gets no header).
// Page numbers count from 1 starting with the first non-cover page.
const allPages = mergedPdf.getPages() const allPages = mergedPdf.getPages()
let bodyPageNum = 0
for (let i = 0; i < allPages.length; i++) { for (let i = 0; i < allPages.length; i++) {
if (pageOwners[i].isCover) continue // no header/number on cover page
bodyPageNum++
const page = allPages[i] const page = allPages[i]
const { width, height } = page.getSize() const { width, height } = page.getSize()
const chapterUpper = pageOwners[i].toUpperCase() const chapterUpper = pageOwners[i].title.toUpperCase()
const headerText = `${projectTitle} / ${chapterUpper} / ${i + 1}` const headerText = `${projectTitle} / ${chapterUpper} / ${bodyPageNum}`
const fontSize = 11 const fontSize = 11
const textWidth = courier.widthOfTextAtSize(headerText, fontSize) const textWidth = courier.widthOfTextAtSize(headerText, fontSize)
page.drawText(headerText, { page.drawText(headerText, {

View File

@@ -62,6 +62,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
max-height: calc(80vh - 120px);
overflow-y: auto;
} }
.settings-field { .settings-field {
@@ -127,6 +129,50 @@
margin: 0; margin: 0;
} }
.settings-section-divider {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-top: 4px;
}
.settings-section-divider::before,
.settings-section-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.settings-textarea {
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 13px;
padding: 7px 10px;
outline: none;
width: 100%;
resize: vertical;
min-height: 64px;
}
.settings-textarea:focus {
border-color: var(--accent);
}
.settings-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.settings-hint--warn { .settings-hint--warn {
color: var(--accent); color: var(--accent);
} }

View File

@@ -12,6 +12,11 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J
const [projectPath, setProjectPath] = useState('') const [projectPath, setProjectPath] = useState('')
const [projectTitle, setProjectTitle] = useState('') const [projectTitle, setProjectTitle] = useState('')
const [originalPath, setOriginalPath] = useState('') const [originalPath, setOriginalPath] = useState('')
const [authorName, setAuthorName] = useState('')
const [penName, setPenName] = useState('')
const [authorAddress, setAuthorAddress] = useState('')
const [authorEmail, setAuthorEmail] = useState('')
const [authorPhone, setAuthorPhone] = useState('')
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
@@ -21,6 +26,11 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J
setProjectPath(cfg.projectPath ?? '') setProjectPath(cfg.projectPath ?? '')
setProjectTitle(cfg.projectTitle ?? '') setProjectTitle(cfg.projectTitle ?? '')
setOriginalPath(cfg.projectPath ?? '') setOriginalPath(cfg.projectPath ?? '')
setAuthorName(cfg.authorName ?? '')
setPenName(cfg.penName ?? '')
setAuthorAddress(cfg.authorAddress ?? '')
setAuthorEmail(cfg.authorEmail ?? '')
setAuthorPhone(cfg.authorPhone ?? '')
}) })
}, []) }, [])
@@ -43,7 +53,16 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J
} }
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
await window.api.writeConfig({ apiKey: apiKey || undefined, projectPath: projectPath || undefined, projectTitle: projectTitle.trim() || undefined }) await window.api.writeConfig({
apiKey: apiKey || undefined,
projectPath: projectPath || undefined,
projectTitle: projectTitle.trim() || undefined,
authorName: authorName.trim() || undefined,
penName: penName.trim() || undefined,
authorAddress: authorAddress.trim() || undefined,
authorEmail: authorEmail.trim() || undefined,
authorPhone: authorPhone.trim() || undefined,
})
if (projectPath !== originalPath) onProjectChanged?.() if (projectPath !== originalPath) onProjectChanged?.()
setOriginalPath(projectPath) setOriginalPath(projectPath)
setSaved(true) setSaved(true)
@@ -109,6 +128,76 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J
/> />
<p className="settings-hint">Used for AI features. Changes take effect immediately.</p> <p className="settings-hint">Used for AI features. Changes take effect immediately.</p>
</div> </div>
<div className="settings-section-divider"><span>Manuscript</span></div>
<div className="settings-field">
<label className="settings-label" htmlFor="settings-author-name">Author Name</label>
<input
id="settings-author-name"
className="settings-input"
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
placeholder="Jane Smith"
spellCheck={false}
/>
<p className="settings-hint">Legal name used on manuscript cover pages and PDF headers.</p>
</div>
<div className="settings-field">
<label className="settings-label" htmlFor="settings-pen-name">Pen Name / Byline</label>
<input
id="settings-pen-name"
className="settings-input"
type="text"
value={penName}
onChange={(e) => setPenName(e.target.value)}
placeholder="J. Smith"
spellCheck={false}
/>
<p className="settings-hint">Name shown as author (e.g. "by…"). Defaults to Author Name if blank.</p>
</div>
<div className="settings-field">
<label className="settings-label" htmlFor="settings-author-address">Address</label>
<textarea
id="settings-author-address"
className="settings-textarea"
value={authorAddress}
onChange={(e) => setAuthorAddress(e.target.value)}
placeholder={"123 Example Street\nCity, Country"}
rows={3}
spellCheck={false}
/>
</div>
<div className="settings-two-col">
<div className="settings-field">
<label className="settings-label" htmlFor="settings-author-email">Email</label>
<input
id="settings-author-email"
className="settings-input"
type="email"
value={authorEmail}
onChange={(e) => setAuthorEmail(e.target.value)}
placeholder="jane@example.com"
spellCheck={false}
/>
</div>
<div className="settings-field">
<label className="settings-label" htmlFor="settings-author-phone">Phone</label>
<input
id="settings-author-phone"
className="settings-input"
type="tel"
value={authorPhone}
onChange={(e) => setAuthorPhone(e.target.value)}
placeholder="+1 555 000 0000"
spellCheck={false}
/>
</div>
</div>
</div> </div>
<div className="settings-footer"> <div className="settings-footer">

View File

@@ -2,6 +2,12 @@ export interface GlobalConfig {
apiKey?: string apiKey?: string
projectPath?: string projectPath?: string
projectTitle?: string projectTitle?: string
// Manuscript metadata
authorName?: string
penName?: string
authorAddress?: string
authorEmail?: string
authorPhone?: string
} }
export interface FileNode { export interface FileNode {

File diff suppressed because one or more lines are too long