// DOCX export — kept in its own module so the (large) `docx` library is only // downloaded when the user actually exports, instead of on every page load. import { BorderStyle, Document, HeadingLevel, Packer, Paragraph, ShadingType, Table, TableCell, TableRow, TextRun, WidthType, } from 'docx'; import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx'; import { detailLines, flatten, timeSummary } from './outline.js'; import { kindLabel } from './outlineLabels.js'; export function createParagraphsFromText(text) { return text .split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .map((line) => new Paragraph({ children: [new TextRun({ text: line })] })); } const DOCX_ACCENT = 'C9A84C'; // antique gold const DOCX_HEADING_FONT = 'Cormorant Garamond'; const DOCX_BODY_FONT = 'Lora'; const DOCX_STYLES = { default: { document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } }, }, paragraphStyles: [ { id: 'Title', name: 'Title', basedOn: 'Normal', next: 'Normal', run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' }, }, { id: 'Heading1', name: 'Heading 1', basedOn: 'Normal', next: 'Normal', run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' }, }, { id: 'Heading2', name: 'Heading 2', basedOn: 'Normal', next: 'Normal', run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' }, }, ], }; export function sectionLabel(text) { return new Paragraph({ spacing: { before: 240, after: 80 }, children: [ new TextRun({ text: text.toUpperCase(), bold: true, color: DOCX_ACCENT, font: DOCX_BODY_FONT, size: 20, characterSpacing: 20, }), ], }); } // The paragraphs of an outline: each point indented by level, with its note, verse and attachments under it. export const outlineParagraphs = (tree) => flatten(tree).flatMap(({ node, depth, label }) => { const main = depth === 0; return [ new Paragraph({ indent: { left: 480 * depth + 480, hanging: 480 }, spacing: { before: main ? 260 : 80, after: 60, line: 300 }, keepNext: main, children: [ new TextRun({ text: `${label}\t`, bold: true, color: 'A84E27', size: main ? 26 : 23 }), ...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind).toUpperCase()} `, bold: true, color: 'A84E27', size: 16, characterSpacing: 20 })] : []), new TextRun({ text: (node.text ?? '').trim() || '(untitled)', bold: main, size: main ? 26 : 23 }), ...(node.ref ? [new TextRun({ text: ` ${node.ref}`, color: '7A7060', size: 19 })] : []), ...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` ${node.minutes} min`, color: '7A7060', size: 19 })] : []), ], }), // note, then the verse's words (set off with a rule), then attachments ...(node.note ?? '').split('\n').filter((l) => l.trim()).map((line) => new Paragraph({ indent: { left: 480 * depth + 480 }, spacing: { after: 40 }, children: [new TextRun({ text: line.trim(), italics: true, color: '6B6458', size: 21 })], })), ...(node.scripture?.text ? [new Paragraph({ indent: { left: 480 * depth + 600 }, spacing: { before: 40, after: 60 }, border: { left: { style: BorderStyle.SINGLE, size: 18, color: 'E4B9A3', space: 8 } }, children: [ new TextRun({ text: `\u201C${node.scripture.text}\u201D`, italics: true, size: 21 }), new TextRun({ text: ` \u2014 ${node.scripture.ref}`, color: '7A7060', size: 19 }), ], })] : []), ...(node.attachments ?? []).map((a) => new Paragraph({ indent: { left: 480 * depth + 480 }, spacing: { after: 20 }, children: [new TextRun({ text: `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}${a.detail ? ` \u2014 ${a.detail}` : ''}`, color: '7A7060', size: 19 })], })), ]; }); // Just one outline, as its own Word document. export async function buildOutlineDocxBlob({ title, subtitle = '', tree }) { const meta = [subtitle, timeSummary(tree)].filter(Boolean).join(' \u00B7 '); const children = [ new Paragraph({ children: [new TextRun({ text: 'OUTLINE', bold: true, color: 'A84E27', size: 18, characterSpacing: 60 })], spacing: { after: 100 } }), new Paragraph({ text: title, heading: HeadingLevel.TITLE }), ...(meta ? [new Paragraph({ children: [new TextRun({ text: meta, color: '7A7060', size: 21 })], spacing: { after: 160 } })] : []), new Paragraph({ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E0D8C6', space: 1 } }, spacing: { after: 120 } }), ...outlineParagraphs(tree), ]; const doc = new Document({ styles: DOCX_STYLES, sections: [{ properties: { page: { margin: { top: 1200, bottom: 1200, left: 1400, right: 1400 } } }, children }] }); return Packer.toBlob(doc); } export async function buildStudyDocxBlob(project, chapterSummary) { const children = [ new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }), new Paragraph({ children: [ new TextRun({ text: `${project.translation} — ${chapterSummary}`, italics: true, color: '7A7060', }), ], spacing: { after: 300 }, }), ]; const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' }; // The outline comes first, indented by level like the page if ((project.outline ?? []).length > 0) { children.push(new Paragraph({ text: 'Outline', heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 160 } })); if (timeSummary(project.outline)) children.push(new Paragraph({ children: [new TextRun({ text: timeSummary(project.outline), italics: true, color: '7A7060' })], spacing: { after: 120 } })); children.push(...outlineParagraphs(project.outline)); } project.chapters.forEach((ch, chapterIndex) => { children.push(new Paragraph({ text: `${ch.book} ${ch.chapter}`, heading: HeadingLevel.HEADING_1, border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } }, spacing: { before: 360, after: 160 }, })); ch.chunks.forEach((chunk) => { const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-'); const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })]; if (chunk.episodeNumber || chunk.episodeTitle) { headingRuns.push(new TextRun({ text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`, italics: true, size: 22, color: '7A7060', })); } children.push(new Paragraph({ children: headingRuns, spacing: { before: 320, after: 120 } })); // Scripture text in a left-bordered table cell for a "callout" look (no fill) const scriptureParas = getChunkVerseEntries(project, chapterIndex, chunk).map((verse) => new Paragraph({ children: [ new TextRun({ text: `${verse.chapter}:${verse.number} `, bold: true, color: DOCX_ACCENT }), new TextRun({ text: verse.text, italics: true }), ], spacing: { after: 80 }, })); children.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ new TableRow({ children: [ new TableCell({ margins: { top: 120, bottom: 120, left: 180, right: 180 }, borders: { ...{ top: noBorder, right: noBorder, bottom: noBorder }, left: { style: BorderStyle.SINGLE, size: 24, color: DOCX_ACCENT }, }, children: scriptureParas, }), ], }), ], })); children.push(new Paragraph({ text: '', spacing: { after: 120 } })); if ((chunk.outline ?? []).length > 0) { children.push(sectionLabel('Outline')); children.push(...outlineParagraphs(chunk.outline)); } if (chunk.generalNotes?.trim()) { children.push(sectionLabel('Background / General Notes')); children.push(...createParagraphsFromText(chunk.generalNotes)); } children.push(sectionLabel('Observation')); children.push(...createParagraphsFromText(chunk.observation || 'No observation.')); children.push(sectionLabel('Interpretation')); children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.')); children.push(sectionLabel('Application')); children.push(...createParagraphsFromText(chunk.application || 'No application.')); if ((chunk.tags ?? []).length > 0) { children.push(sectionLabel('Tags')); children.push(new Paragraph({ children: chunk.tags.map((tag, i) => new TextRun({ text: i === 0 ? `#${tag}` : ` #${tag}`, bold: true, color: DOCX_ACCENT, })), })); } if ((chunk.crossReferences ?? []).length > 0) { children.push(sectionLabel('Cross-References')); children.push(new Paragraph({ text: chunk.crossReferences.join(' • ') })); } if (chunk.greekWords.length > 0) { children.push(sectionLabel('Word Studies')); const headerCellStyle = (label) => new TableCell({ width: { size: 20, type: WidthType.PERCENTAGE }, borders: { bottom: { style: BorderStyle.SINGLE, size: 8, color: DOCX_ACCENT } }, children: [new Paragraph({ children: [new TextRun({ text: label, bold: true, color: DOCX_ACCENT })] })], }); const tableRows = [ new TableRow({ tableHeader: true, children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map(headerCellStyle), }), ...chunk.greekWords.map((word) => new TableRow({ children: [ new TableCell({ children: [new Paragraph(word.strongNumber || '')] }), new TableCell({ children: [new Paragraph(word.lexeme || '')] }), new TableCell({ children: [new Paragraph(word.transliteration || '')] }), new TableCell({ children: [new Paragraph(word.partOfSpeech || '')] }), new TableCell({ children: [new Paragraph(word.shortDefinition || '')] }), ], })), ]; children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE } })); chunk.greekWords.forEach((word) => { if (word.definitionHtml) { children.push(new Paragraph({ spacing: { before: 180, after: 80 }, children: [new TextRun({ text: `${word.strongNumber} — ${word.lexeme || ''}`, bold: true, color: DOCX_ACCENT })], })); children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml))); } }); } if (chunk.finalScript?.trim()) { children.push(sectionLabel('Final Script')); children.push(...createParagraphsFromText(chunk.finalScript)); } children.push(new Paragraph({ text: '', spacing: { after: 200 }, border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: '2A2518', space: 8 } }, })); }); }); const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] }); return Packer.toBlob(doc); } // A journal of bookmarks, text highlights, and completed reading plans — see // utils/journalExport.js for how this data is gathered. export async function buildJournalDocxBlob({ bookmarks, highlights, completedPlans }) { const children = [ new Paragraph({ text: 'My Reading Journal', heading: HeadingLevel.TITLE }), new Paragraph({ children: [new TextRun({ text: `Generated ${new Date().toLocaleDateString()}`, italics: true, color: '7A7060' })], spacing: { after: 300 }, }), ]; const heading = (text) => new Paragraph({ text, heading: HeadingLevel.HEADING_1, border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } }, spacing: { before: 360, after: 160 }, }); if (completedPlans.length > 0) { children.push(heading('Reading Plans Completed')); completedPlans.forEach(({ plan, completedAt }) => { children.push(new Paragraph({ children: [ new TextRun({ text: `${plan.icon ?? '✓'} ${plan.title}`, bold: true, color: DOCX_ACCENT }), new TextRun({ text: ` Completed ${new Date(completedAt).toLocaleDateString()} · ${plan.days} days`, italics: true, color: '7A7060' }), ], spacing: { after: 80 }, })); }); } if (bookmarks.length > 0) { children.push(heading('Bookmarked Verses')); bookmarks.forEach((b) => { children.push(new Paragraph({ children: [new TextRun({ text: `${b.bookName} ${b.chapter}:${b.verse}`, bold: true, color: DOCX_ACCENT })], spacing: { before: 160 }, })); if (b.text) { children.push(new Paragraph({ children: [new TextRun({ text: b.text, italics: true })], spacing: { after: 40 } })); } }); } if (highlights.length > 0) { children.push(heading('Highlighted Passages')); highlights.forEach((h) => { children.push(new Paragraph({ children: [new TextRun({ text: `${h.bookName} ${h.chapter}:${h.verse}`, bold: true, color: DOCX_ACCENT })], spacing: { before: 160 }, })); if (h.fullText) { children.push(new Paragraph({ children: [ new TextRun({ text: h.fullText.slice(0, h.startOffset), italics: true }), new TextRun({ text: h.excerpt ?? '', italics: true, bold: true, shading: { type: ShadingType.CLEAR, fill: (h.color || '#FFF176').replace('#', '') }, }), new TextRun({ text: h.fullText.slice(h.endOffset), italics: true }), ], spacing: { after: 40 }, })); } }); } if (completedPlans.length + bookmarks.length + highlights.length === 0) { children.push(new Paragraph({ text: "Nothing saved yet — bookmark a verse, highlight some text, or finish a reading plan to build your journal.", })); } const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] }); return Packer.toBlob(doc); }