diff --git a/assets/css/app.css b/assets/css/app.css index d1bb280..0c1d8c1 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -3,20 +3,23 @@ "Times New Roman", serif; } +/* Only A-Z and a-z: the compiler puts a drop cap on a chapter only when it + opens on a letter. */ +@font-face { + font-family: "Day Leaf Initials"; + src: url("../fonts/DayLeafInitials.woff2") format("woff2"); + font-display: swap; +} + /* Both of these are links to their own id. They keep the text colour they had as plain numbers, since an underlined blue verse number in the middle of a sentence reads as part of the text. */ .v, .chapter-number a { - color: var(--bs-secondary-color); + color: var(--bs-body-color); text-decoration: none; } -.v:hover, -.chapter-number a:hover { - color: var(--bs-emphasis-color); -} - .v { font-size: .7em; font-weight: 700; @@ -33,13 +36,99 @@ border-radius: 2px; } +/* Three lines deep. The verse number it replaces is still there for its id + and the keyboard, as the chapter number above already says where verse 1 + is -- the way a printed Bible drops it. */ +.dropcap { + float: left; + font-family: "Day Leaf Initials", var(--bs-body-font-family); + font-size: 5em; + line-height: .8; + margin: .06em .08em 0 0; +} + +/* The first line pulled in closer to the letter than the lines below it, so + the rest of the first word reads as part of it. .chapter makes this beat + the list's and the poetry's own first-line indents. */ +.chapter :is(p, li):has(.dropcap) { text-indent: -.4em; } + +/* The rest of the first line in small caps, as a lead-in from the initial. + The letter can sit inside a span, such as .wj. */ +:is(p, li):has(.dropcap)::first-line { + font-variant: small-caps; + letter-spacing: .05em; +} + +.v:has(+ .dropcap):not(:focus-visible) { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The book's title, centred with room around it. --u shrinks with the header + on a phone. */ +.book-header { + --u: min(1rem, 100cqi / 22); + container-type: inline-size; + display: grid; + place-items: center; + padding: calc(var(--u) * 4.5) calc(var(--u) * 3.5); + margin: 1rem 0 2rem; +} + +.book-header h1 { + max-width: 100%; + margin: 0; + padding: .6em 1.2em; + text-align: center; + font-weight: 400; + line-height: 1.15; + text-wrap: balance; +} + +/* The lines above and below the name itself, such as "The First Book of + Moses, Commonly Called", on lines of their own in small caps. */ +.book-header :is(.mt1, .mt2, .mt3, .mt4) { + display: block; + font-size: .45em; + font-variant: small-caps; + letter-spacing: .08em; + margin: .3em 0; +} + +.book-header .mt { display: block; } + +/* Centred between two flourishes (Group L 49), the left one mirrored so + their curls point in at the number. */ .chapter-number { + display: flex; + align-items: center; + justify-content: center; + gap: .4em; font-size: 2rem; font-weight: 400; color: var(--bs-secondary-color); - margin: 2rem 0 .75rem; + margin: 5rem 0 .75rem; } +.chapter-number::before, +.chapter-number::after { + content: ""; + flex: none; + width: 3.2em; + height: .8em; + /* Flex centres on the line box, which leaves room below for descenders; + numerals have none, so this lifts the flourish to their middle. */ + translate: 0 -.1em; + background: currentColor; + mask: url("../ornaments/group-l-49.svg") center / contain no-repeat; +} + +.chapter-number::before { transform: scaleX(-1); } + /* The export marks these up as list items but hides the marker, indenting the first line instead, which is how a printed Bible sets them -- Ezra 10:18-44 and the like are prose, not a bulleted list. Only the glossary and front @@ -66,6 +155,12 @@ .poetry .line[data-indent="2"] { padding-left: 3em; } .poetry .line[data-indent="3"] { padding-left: 4.5em; } +/* A poetry line's indent is padding, which a float covers. Each line after + the drop cap becomes its own block beside the letter, so its indent counts + from the letter's edge; the drop cap's own line has no indent of its own. */ +.poetry .line:has(.dropcap) { padding-left: 0; } +.poetry .line:not(:has(.dropcap)) { display: flow-root; } + .d { font-style: italic; color: var(--bs-secondary-color); diff --git a/assets/fonts/DayLeafInitials.woff2 b/assets/fonts/DayLeafInitials.woff2 new file mode 100644 index 0000000..19dbe8c Binary files /dev/null and b/assets/fonts/DayLeafInitials.woff2 differ diff --git a/assets/ornaments/group-l-49.svg b/assets/ornaments/group-l-49.svg new file mode 100644 index 0000000..a008c35 --- /dev/null +++ b/assets/ornaments/group-l-49.svg @@ -0,0 +1 @@ + diff --git a/assets/sw.js b/assets/sw.js index 09512f7..31a5dbc 100644 --- a/assets/sw.js +++ b/assets/sw.js @@ -23,6 +23,8 @@ const PRECACHE = [ './manifest.webmanifest', './css/app.css', './css/bootstrap.min.css', + './fonts/DayLeafInitials.woff2', + './ornaments/group-l-49.svg', './js/htmx.min.js', './js/bootstrap.bundle.min.js' ].concat(TEMPLATE_URLS); diff --git a/server.py b/server.py index cd46a51..f506610 100644 --- a/server.py +++ b/server.py @@ -44,6 +44,7 @@ class Handler(SimpleHTTPRequestHandler): ".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", + ".woff2": "font/woff2", } server_version = "bible-static" sys_version = "" diff --git a/utils/compile_bible.py b/utils/compile_bible.py index c01d11e..d9cc46a 100644 --- a/utils/compile_bible.py +++ b/utils/compile_bible.py @@ -453,11 +453,51 @@ def ref_href(node, book, chapter): return "%s#%s" % (base, verse_id(target_ch, verse)) return "%s#ch%d" % (base, target_ch) +DROPCAP_BLOCKS = ("paragraph", "line", "item") + +def with_dropcap(nodes): + """Split the first letter of the first text off into a dropcap node. + + Returns the new node list, or None when the text does not start with a + letter. The initials font has only A-Z and a-z, so a chapter opening on + a quotation mark or a digit gets no drop cap rather than a fallback one. + """ + nodes = list(nodes) + for i, node in enumerate(nodes): + kind = node["t"] + if kind == "verse": + continue + # A note caller would land between the letter and the rest of its word. + if kind == "noteref": + return None + if kind == "text": + text = node["v"].lstrip() + if not text: + continue + if not ("A" <= text[0] <= "Z" or "a" <= text[0] <= "z"): + return None + nodes[i:i + 1] = [{"t": "dropcap", "v": text[0]}, + dict(node, v=text[1:])] + else: + inner = with_dropcap(node.get("c", [])) + if inner is None: + return None + nodes[i] = dict(node, c=inner) + # The verse number before the drop cap would otherwise sit between + # the floated letter and the rest of its word. + for j in range(i): + if nodes[j]["t"] == "verse": + nodes[j] = dict(nodes[j], dropcap=True) + return nodes + return None + def render_inline(nodes, book, chapter, out): for node in nodes: kind = node["t"] if kind == "text": out.append(esc(node["v"])) + elif kind == "dropcap": + out.append('%s' % esc(node["v"])) elif kind == "verse": label = esc(node["label"]) vid = verse_id(chapter, node["label"]) @@ -466,10 +506,13 @@ def render_inline(nodes, book, chapter, out): # that dialog. Being a real link is also what gives it the # keyboard and context menu a reader expects. out.append('%s ' + 'aria-label="Verse %s, options">%s' % (vid, vid, label, label)) + # A non-breaking space keeps the number on the same line as the + # word it introduces, as the source does. Before a drop cap the + # number is hidden and the space would only push the word away. + if not node.get("dropcap"): + out.append(" ") elif kind == "noteref": n = node["id"].replace("FN", "") out.append('' @@ -550,8 +593,14 @@ def render_chapter(chapter, book, out): out.append(" \n") group = None + dropcap = True for block in chapter["blocks"]: btype = block["type"] + if dropcap and btype in DROPCAP_BLOCKS: + dropcap = False + content = with_dropcap(block.get("content", [])) + if content is not None: + block = dict(block, content=content) if btype == "break": close_group() continue