Files
study-bible-project/src/utils/docxExport.js
T
nmemmertandClaude Sonnet 5.5 820886e3ab Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top);
  chunk outlines can start from the chunk's verses, each carrying its words
- Points can have a type (illustration, application, quote, question,
  transition), minutes (with a running total), a longer note, verse text
  fetched from a passage, and cross-references / word studies attached from
  the chunk
- Share (system share sheet, or copy), Print / PDF, Copy as text, Word and
  Markdown downloads for any one outline
- All of it included in the Markdown, HTML/PDF and Word study exports,
  with each chunk's outline under that chunk

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:28 -04:00

342 lines
13 KiB
JavaScript

// 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 }) => [
new Paragraph({
indent: { left: 420 * depth, hanging: 420 },
spacing: { after: 60 },
children: [
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind)}: `, italics: true, color: '92400E' })] : []),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` · ${node.minutes} min`, color: '7A7060' })] : []),
],
}),
...detailLines(node).map((line) => new Paragraph({
indent: { left: 420 * depth + 420 },
spacing: { after: 40 },
children: [new TextRun({ text: line, italics: true, color: '4B5563', size: 20 })],
})),
]);
// Just one outline, as its own Word document.
export async function buildOutlineDocxBlob({ title, subtitle = '', tree }) {
const children = [
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
new Paragraph({ children: [new TextRun({ text: [subtitle, timeSummary(tree)].filter(Boolean).join(' · '), italics: true, color: '7A7060' })], spacing: { after: 300 } }),
...outlineParagraphs(tree),
];
const doc = new Document({ styles: DOCX_STYLES, sections: [{ 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);
}