Outline: restyle the share page and the printed / Word outline
- Read-only share page: a calm paper-toned page rendered natively (no iframe), with outline tabs, Print / PDF, numbered points with accent numerals, type tags, notes and verse quotes; works on phones - Printable / PDF page restyled to match (serif, hanging numerals, tidy spacing, footer) - Word outline restyled: eyebrow + title block, bold main points, accent numerals, set-off verse quotes, tidy margins Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34d04fd077
commit
57db2310dd
6 files changed
+184
-71
No files matched your search
+33
-17
@@ -334,23 +334,36 @@ function outlineListHtml(nodes, depth) {
|
||||
// A page of just one outline (for printing or saving as PDF): the title, the passage, the points.
|
||||
export function buildOutlineHtml({ title, subtitle = '', tree }) {
|
||||
const total = flattenMinutes(tree);
|
||||
const meta = [subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');
|
||||
// Simple and calm: paper tones, a serif face, hanging numerals in the accent colour, and generous space.
|
||||
const style = `
|
||||
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 40px; }
|
||||
.page { max-width: 760px; margin: auto; }
|
||||
h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
|
||||
.sub { color: #64748b; margin: 0 0 1.5rem; }
|
||||
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.6rem; padding: 0; }
|
||||
.outline > ul { margin-left: 0; }
|
||||
.outline li { margin: 0.45rem 0; line-height: 1.5; }
|
||||
.outline > ul > li { margin-top: 0.9rem; font-weight: 600; }
|
||||
.outline > ul > li li { font-weight: 400; }
|
||||
.lbl { display: inline-block; min-width: 2.1rem; font-weight: 700; }
|
||||
.ref { color: #64748b; font-size: 0.9em; font-weight: 400; }
|
||||
.kind { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; font-weight: 400; }
|
||||
.o-note { margin: 0.2rem 0 0 2.1rem; color: #334155; font-size: 0.95em; font-weight: 400; }
|
||||
.o-quote { margin: 0.3rem 0 0.3rem 2.1rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; font-weight: 400; }
|
||||
.o-attach { margin-left: 2.1rem; font-size: 0.85em; color: #64748b; font-weight: 400; }
|
||||
@media print { body { padding: 0; } li { break-inside: avoid; } }
|
||||
:root { --text: #2b2620; --sub: #6b6458; --accent: #a84e27; --line: #e0d8c6; --paper: #faf7f0; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Georgia, 'Times New Roman', serif; color: var(--text); background: var(--paper); margin: 0; padding: 48px 24px 64px; -webkit-font-smoothing: antialiased; }
|
||||
.page { max-width: 720px; margin: 0 auto; }
|
||||
.eyebrow { font: 600 0.72rem/1 system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); margin: 0 0 0.9rem; }
|
||||
h1 { margin: 0; font-size: 2.1rem; line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; }
|
||||
.sub { font: 0.9rem/1.4 system-ui, sans-serif; color: var(--sub); margin: 0.6rem 0 0; }
|
||||
.rule { height: 1px; background: var(--line); margin: 1.6rem 0 0.4rem; border: 0; }
|
||||
.outline ul { list-style: none; margin: 0; padding: 0; }
|
||||
.outline ul ul { margin-left: 2.4rem; }
|
||||
.outline li { margin: 0.55rem 0; line-height: 1.55; font-size: 1.05rem; }
|
||||
.outline > ul > li { margin-top: 1.5rem; font-size: 1.15rem; font-weight: 600; }
|
||||
.outline > ul > li li { font-weight: 400; font-size: 1.05rem; }
|
||||
.lbl { display: inline-block; min-width: 2.4rem; color: var(--accent); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.ref { color: var(--sub); font: 0.82em/1 system-ui, sans-serif; font-weight: 400; white-space: nowrap; }
|
||||
.kind { font: 600 0.62rem/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); border: 1px solid #e4b9a3; border-radius: 9999px; padding: 0.2em 0.65em; margin-right: 0.5em; vertical-align: 0.15em; }
|
||||
.o-note { margin: 0.25rem 0 0 2.4rem; color: var(--sub); font-size: 0.95rem; font-weight: 400; font-style: italic; }
|
||||
.o-quote { margin: 0.5rem 0 0.4rem 2.4rem; padding: 0.1rem 0 0.1rem 1rem; border-left: 3px solid #e4b9a3; font-style: italic; font-weight: 400; font-size: 1rem; }
|
||||
.o-attach { margin: 0.3rem 0 0 2.4rem; font: 0.8rem/1.5 system-ui, sans-serif; color: var(--sub); font-weight: 400; }
|
||||
.foot { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); font: 0.72rem/1 system-ui, sans-serif; color: var(--sub); text-align: center; letter-spacing: 0.04em; }
|
||||
@media (max-width: 520px) { body { padding: 32px 18px 48px; } h1 { font-size: 1.7rem; } .outline ul ul { margin-left: 1.5rem; } .o-note, .o-quote, .o-attach { margin-left: 1.5rem; } }
|
||||
@media print {
|
||||
body { background: #fff; padding: 0; }
|
||||
.page { max-width: none; }
|
||||
li { break-inside: avoid; }
|
||||
.outline > ul > li { break-after: avoid; }
|
||||
}
|
||||
`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -362,9 +375,12 @@ export function buildOutlineHtml({ title, subtitle = '', tree }) {
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<p class="eyebrow">Outline</p>
|
||||
<h1>${escHtml(title)}</h1>
|
||||
<p class="sub">${escHtml([subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '))}</p>
|
||||
${meta ? `<p class="sub">${escHtml(meta)}</p>` : ''}
|
||||
<hr class="rule" />
|
||||
<div class="outline">${outlineListHtml(tree, 0)}</div>
|
||||
<p class="foot">Bible Study Project</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
+42
-20
@@ -75,33 +75,55 @@ export function sectionLabel(text) {
|
||||
}
|
||||
|
||||
// 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 })],
|
||||
})),
|
||||
]);
|
||||
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 }),
|
||||
new Paragraph({ children: [new TextRun({ text: [subtitle, timeSummary(tree)].filter(Boolean).join(' · '), italics: true, color: '7A7060' })], spacing: { after: 300 } }),
|
||||
...(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: [{ children }] });
|
||||
const doc = new Document({ styles: DOCX_STYLES, sections: [{ properties: { page: { margin: { top: 1200, bottom: 1200, left: 1400, right: 1400 } } }, children }] });
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,3 +54,13 @@ test('the printable page escapes text and shows notes', () => {
|
||||
expect(html).toContain('<i>hi</i>');
|
||||
expect(html).toContain('class="o-note"');
|
||||
});
|
||||
|
||||
test('the Word document builds, with and without the extras', async () => {
|
||||
const { buildOutlineDocxBlob } = await import('./docxExport.js');
|
||||
const blob = await buildOutlineDocxBlob({
|
||||
...outline,
|
||||
tree: [{ ...outline.tree[0], scripture: { ref: 'Titus 1:5', text: 'appoint elders', translation: 'BSB' }, attachments: [{ id: 'x', type: 'word', label: 'presbyteros', detail: 'elder' }] }, { id: 'z', text: '', ref: '', children: [] }],
|
||||
});
|
||||
expect(blob.size).toBeGreaterThan(1000);
|
||||
expect((await buildOutlineDocxBlob({ title: 'Empty', tree: [] })).size).toBeGreaterThan(500);
|
||||
});
|
||||
Reference in new issue
Block a user