diff --git a/docs/library.md b/docs/library.md index 597eb08..ac99a83 100644 --- a/docs/library.md +++ b/docs/library.md @@ -59,3 +59,25 @@ PDF: page 1), scales it to 240 px wide JPEG, **encrypts it with the user's key** {cover_ct, cover_iv}`. `GET /books/` lists `has_cover`; `GET /books//cover/` returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The server never sees a cover. + +### Generated covers + +A book without a cover (or one set to `cover: 'generated'` in its meta) gets one +drawn by `static/js/gencover.js`: an SVG, the same on every device, never stored. +A **concept** (motif, palettes, typeface) is chosen by score: + +- words in the title (3), the shelf category (2) and the author (2, only at a + name's start). A stem of five letters or more counts anywhere (German + compounds), a shorter one at a word's start, one of three letters as a whole + word; `^` / `$` in the list force a word's start / end. +- the folders, by name (`FOLDER_THEMES`): the first counts 2.5 — more than an + author or shelf word, less than a title word — further ones 1. + +Concepts: konstruktiv (Marx), geometrie (Philosophie: rings, turning squares, +triangle/circle/square), psyche (Psychoanalyse: two ellipses, the Borromean +knot, a spiral), karte (Geschichte), kosmos (Science-Fiction), ornament +(Fantasy), raster (Politik, Soziologie), schatten (Krimi: a red line, a lit +doorway), grusel (Horror), klassiker (patterned paper with a title plate), +sakral (Religion & Mythologie), spiel (Kinder & Jugend), code (Informatik), +typo, wellen, botanik, berge, zellen — and bauhaus when nothing matches. A hash +of title and author picks palette, layout and every detail. diff --git a/static/js/gencover.js b/static/js/gencover.js index 9f023aa..0d60152 100644 --- a/static/js/gencover.js +++ b/static/js/gencover.js @@ -3,7 +3,9 @@ * device, from what the book is called and where it stands — its title, author, shelf * category and folders. Words in them point to a design concept (a motif, curated * palettes, a typeface: Marx and Kapital to constructivism, Hegel and Dialektik to quiet - * geometry, Meer to waves, Krieg to map contours …); a hash of title and author picks + * geometry, Meer to waves, Krieg to map contours …), and so do the folders, by name + * (Klassiker to a patterned Insel-style cover, Horror to a bare tree before the moon …); + * a hash of title and author picks * the palette, the layout and every detail of the motif, so no two books look alike and * each always looks the same. Nothing is stored or sent: it's an SVG, made when needed. * @@ -38,7 +40,12 @@ const f1 = n => Math.round(n * 10) / 10; // ---- the concepts --------------------------------------------------------------------------- - // words: stems found anywhere in the text (lower case, umlauts as they are); + // words: stems in the text (lower case, umlauts as they are). One of five letters or + // more counts anywhere (German compounds: "Massenpsychologie"); a shorter one only at + // the start of a word, and one of three letters only as a word of its own (plural + // allowed) — "gen" isn't in "Leiden", "war" not in "Gegenwart" or "Wardrobe". + // A leading ^ asks for a word start whatever the length ("stern", not in "Eastern"), + // a trailing $ for a word's end as well ("king", not "Kingdom"). // palettes: [background, ink, accent, second accent], curated to sit well together. const SERIF = "Georgia, 'Times New Roman', serif"; @@ -55,28 +62,29 @@ {id: 'geometrie', font: SERIF, words: ['philosoph', 'hegel', 'kant', 'dialekt', 'dialect', 'vernunft', 'reason', 'geist', 'spirit', 'ethik', 'ethic', 'logik', 'logic', 'metaphys', 'phänomen', 'phenomen', 'denken', 'wahrheit', 'truth', 'sein', 'nichts', 'nothing', + 'pragmati', 'pessimis', 'flatland', 'dimension', 'leviathan', 'republic', 'meditation', 'treatise', 'abhandlung', 'kritik', 'critique', 'theorie', 'theory', 'begriff', 'wissen'], palettes: [['#f1ece1', '#2b2a27', '#b08a3e', '#6f7d72'], ['#26292e', '#ece4d4', '#c9a25a', '#8a9aa8'], ['#e7e1d6', '#3a3833', '#8c5a3c', '#5d6b74']]}, {id: 'psyche', font: SERIF, words: ['lacan', 'freud', 'psycho', 'sex', 'begehren', 'desire', 'disavowal', 'verleugn', 'unbewusst', 'unconscious', - 'comedy', 'komik', 'komöd', 'parallax', 'real', 'voice', 'stimme', 'žižek', 'zizek', 'zupan', 'dolar', 'traum', 'dream', + 'comedy', 'komik', 'komöd', 'parallax', 'of the real', 'voice', 'stimme', 'žižek', 'zizek', 'zupan', 'dolar', 'traum', 'dream', 'odd one', 'antigone', 'let them rot', 'klavierspielerin'], palettes: [['#f3e3d9', '#3d2440', '#c45b6a', '#5c8a8a'], ['#2c2236', '#f2dfd2', '#e07a6f', '#7fb3ae'], ['#e8ddeb', '#2e2a3d', '#8d4f8a', '#d99a6c']]}, {id: 'wellen', font: SERIF, words: ['meer', 'sea', 'ozean', 'ocean', 'wasser', 'water', 'insel', 'island', 'fluss', 'river', 'flut', 'flood', 'deluge', - 'sintflut', 'schiff', 'ship', 'welle', 'wave', 'strand', 'küste', 'coast', 'regen', 'rain'], + 'sintflut', 'schiff', 'ship', 'welle', 'wave', 'strand', 'küste', 'coast', 'regen', 'rain', 'venedig', 'venice'], palettes: [['#e5eef0', '#13324a', '#2f6f8f', '#8fb8c6'], ['#0f2a3d', '#e3edf0', '#4c95b0', '#a9d3dc'], ['#efe9dc', '#1f3b4d', '#3d7a8c', '#c98a5a']]}, {id: 'karte', font: SERIF, upper: true, - words: ['krieg', 'war', 'geschichte', 'history', 'empire', 'imperium', 'reich', 'zerstörung', 'destruction', 'wages', + words: ['krieg', 'war', 'history', 'empire', 'imperium', 'reich', 'zerstörung', 'destruction', 'wages', 'nazi', 'staat', 'state', 'nation', 'kolonial', 'colonial', 'weltkrieg', 'schlacht', 'battle', 'grenze', 'border', 'seeing like', 'world-system', 'weltsystem'], palettes: [['#e9e1cc', '#3b3a2c', '#8a4b2a', '#6d7a4f'], ['#2f3127', '#e6dcc4', '#b5763c', '#8e9a6b'], ['#ddd5bd', '#2c2a22', '#7a3b2e', '#5f6f63']]}, {id: 'kosmos', font: SANS, upper: true, - words: ['stern', 'star', 'kosmos', 'cosmos', 'space', 'weltraum', 'planet', 'mond', 'moon', 'dune', 'nacht', 'night', + words: ['^stern', 'star', 'kosmos', 'cosmos', 'space', 'weltraum', 'planet', 'mond', 'moon', 'dune', 'nacht', 'night', 'universum', 'universe', 'zeit', 'time', 'licht', 'light', 'galax', 'messiah', 'science fiction', 'sci-fi', 'zukunft', 'future'], palettes: [['#141833', '#ece6d3', '#e3b65b', '#7d8bd4'], ['#0e1424', '#dfe6f2', '#d9824a', '#6fa3c4'], ['#1f1a33', '#efe7da', '#c9a8e0', '#e3c46b']]}, {id: 'ornament', font: SERIF, - words: ['fantasy', 'drache', 'dragon', 'magie', 'magic', 'prophezei', 'prophecy', 'throne', 'thron', 'könig', 'king', + words: ['fantasy', 'drache', 'dragon', 'magie', 'magic', 'prophezei', 'prophecy', 'throne', 'thron', 'könig', 'king$', 'krone', 'crown', 'schwert', 'sword', 'warrior', 'krieger', 'cats', 'katze', 'legende', 'legend', 'märchen', 'fairy', 'zauber', 'spell', 'ice and fire', 'bundle'], palettes: [['#1e2a2a', '#efe3c4', '#c69a3c', '#8f3b46'], ['#2b1d2e', '#f0e2c8', '#d3a13f', '#3f7a6d'], ['#f0e6d0', '#2d2433', '#8c3a4a', '#2f6b65']]}, @@ -85,12 +93,13 @@ 'frühling', 'spring', 'sommer', 'summer', 'pflanze', 'plant', 'wurzel', 'root', 'gras', 'grass', 'pilz', 'mushroom'], palettes: [['#eef0e4', '#23301f', '#4f7a43', '#b7a35a'], ['#203024', '#e8ecdc', '#86a86b', '#d6b56a'], ['#f2ece0', '#2b3326', '#6f8f5a', '#c27c5a']]}, {id: 'berge', font: SANS, upper: true, - words: ['berg', 'mountain', 'alpen', 'alps', 'gipfel', 'peak', 'zauberberg', 'hügel', 'hill', 'landschaft', 'landscape', + words: ['berg', 'mountain', 'alpen', 'alps', 'gipfel', 'peak', 'zauberberg', 'hügel', 'landschaft', 'landscape', 'schnee', 'snow', 'winter', 'eis', 'ice', 'tal', 'valley', 'wandern'], palettes: [['#e8ecef', '#22303b', '#5b7a8f', '#a9bccb'], ['#24323d', '#eef1f2', '#7f9fb3', '#c8d6de'], ['#efe9e0', '#2e3438', '#6f8590', '#c7a27a']]}, {id: 'zellen', font: SANS, words: ['biolog', 'gehirn', 'brain', 'mensch', 'human', 'behave', 'verhalten', 'behavio', 'sucht', 'addict', 'enough', - 'körper', 'body', 'medizin', 'medic', 'gen', 'evolution', 'neuro', 'zelle', 'cell', 'leben', 'life', 'tier', 'animal'], + 'körper', 'body', 'medizin', 'medic', 'genet', 'genom', 'evolution', 'neuro', 'zelle', 'cell', 'leben', 'life', 'tier', + 'animal', 'species', 'darwin', 'beagle', 'natural selection'], palettes: [['#f1ebe4', '#26323a', '#e0765c', '#4f9a94'], ['#1f2b31', '#eef0ea', '#f08a6c', '#69b3aa'], ['#eaf0ee', '#22303a', '#d4634e', '#3f8f8a']]}, {id: 'raster', font: SANS, words: ['stadt', 'city', 'urban', 'risiko', 'risk', 'gesellschaft', 'society', 'moderne', 'modern', 'system', 'analys', @@ -99,12 +108,49 @@ palettes: [['#ecebe7', '#1f2328', '#d1453b', '#2f5f8f'], ['#22262b', '#ebe8e1', '#e2b33c', '#4f86b8'], ['#f0ede6', '#2a2d31', '#3f6fa0', '#d9a03a']]}, {id: 'schatten', font: NARROW, upper: true, words: ['tod', 'death', 'mord', 'murder', 'crime', 'verbrechen', 'schatten', 'shadow', 'dunkel', 'dark', 'angst', 'fear', - 'serotonin', 'blut', 'blood', 'gift', 'poison', 'detektiv', 'detective', 'kommissar', 'thriller'], + 'serotonin', 'blut', 'blood', 'gift', 'poison', 'detektiv', 'detective', 'kommissar', 'thriller', 'krimi', 'sherlock', + 'christie', 'lupin', 'burglar', 'einbrecher'], palettes: [['#151515', '#ecebe6', '#c2302f', '#5a5a5a'], ['#ecebe6', '#151515', '#a8241f', '#7c7c7c'], ['#1c1f24', '#e4e0d8', '#d0a04a', '#55606b']]}, {id: 'typo', font: SERIF, words: ['roman', 'novel', 'erzählung', 'stories', 'geschichten', 'gedicht', 'poem', 'poesie', 'poetry', 'brief', 'letters', 'tagebuch', 'diary', 'molloy', 'beckett', 'manuskripte', 'literatur', 'literature', 'review', 'zeitschrift'], palettes: [['#f2ede3', '#211f1c', '#9b3b2f', '#7d7466'], ['#24211e', '#f0e9dc', '#d39b5f', '#8f8578'], ['#e9e4f0', '#26222e', '#5b4a8a', '#a3909a']]}, + // A patterned paper with a title plate, like the Insel-Bücherei. + {id: 'klassiker', font: SERIF, layouts: ['plate'], frame: true, + words: ['klassik', 'classic', 'sämtliche werke', 'gesammelte werke', 'works of'], + palettes: [['#e9dcc0', '#2a2620', '#a8452f', '#c9a86a'], ['#d9e1d6', '#22302a', '#3f6b57', '#b58a4a'], ['#e4d9e6', '#2c2433', '#6d3f73', '#c09a5a'], + ['#dfe4ea', '#1f2733', '#2f557a', '#c5953f'], ['#f0e3d3', '#2d2420', '#7e2f3a', '#3f6f74']]}, + {id: 'sakral', font: SERIF, upper: true, + words: ['bibel', 'bible', 'gott', 'koran', 'quran', 'edda', 'mytholog', 'myth', 'heilig', 'holy', 'sacred', 'kirche', + 'church', 'religio', 'evangel', 'gospel', 'testament', 'septuaginta', 'kojiki', 'heldensage', 'sagen', 'beowulf', + 'götter', 'divine', 'paradise', 'paradies', 'hölle', 'hell'], + palettes: [['#1d2440', '#efe4c8', '#c9a245', '#7d4b8a'], ['#3a1c1f', '#f1e5cc', '#d1a548', '#4f7a8a'], ['#efe6d2', '#2b2218', '#9a6b22', '#7a2e2a']]}, + {id: 'grusel', font: SERIF, upper: true, + words: ['horror', 'grusel', 'gothic', 'vampir', 'dracula', 'gespenst', 'ghost', 'spuk', 'haunt', 'cthulhu', 'monster', + 'frankenstein', 'sleepy hollow', 'borderland', 'poe', 'shining', 'scream', 'lovecraft'], + palettes: [['#16141a', '#e8e2d6', '#9e2a2b', '#d9d2bf', '#08070a'], ['#1d2320', '#e3e6dc', '#7d9a6a', '#c9cfb5', '#090c0a'], + ['#e3dccd', '#1a1718', '#8c1c1c', '#f4efe4', '#1a1718']]}, + {id: 'spiel', font: SANS, + words: ['kinder', 'children', 'kids', 'jugend', 'abenteuer', 'adventure', 'alice', 'looking-glass', 'wonderland', + 'treasure', 'schatz', 'green gables', 'narnia', 'wardrobe', 'coraline', 'unendliche'], + palettes: [['#fdf3e1', '#2d2a26', '#f2a23a', '#4fa3a5'], ['#e8f4f1', '#23343a', '#ef7b5a', '#f4c54b'], ['#2f3e6b', '#fff6e6', '#f6c445', '#ef8a6f'], + ['#f6ecf2', '#2c2433', '#e0607e', '#5bb08f']]}, + {id: 'code', font: MONO, + words: ['program', 'comput', 'informatik', 'software', 'algorithm', 'linux', 'unix', 'python', 'javascript', + 'rhcsa', 'red hat', 'interpretation of computer'], + palettes: [['#1e2229', '#e6e1d6', '#e5a93d', '#6fb3c9'], ['#f4f1ea', '#22262c', '#c2453a', '#3d7ab0'], ['#0f1a14', '#d8eadc', '#5fd08a', '#e0c068']]}, + ]; + + // Folders say what a book is when its title doesn't: by name (lower case, a part of it). + const FOLDER_THEMES = [ + ['marx', 'konstruktiv'], ['ökonom', 'konstruktiv'], ['ljubljana', 'psyche'], ['psychoanaly', 'psyche'], + ['kritische theorie', 'geometrie'], ['philosoph', 'geometrie'], ['poststruktur', 'raster'], ['zeitdiagnos', 'raster'], + ['politik', 'raster'], ['soziolog', 'raster'], ['gesellschaft', 'raster'], ['shadowrun', 'raster'], + ['psycholog', 'zellen'], ['wissenschaft', 'zellen'], ['biolog', 'zellen'], ['geschicht', 'karte'], + ['informatik', 'code'], ['schreib', 'typo'], ['gegenwartslit', 'typo'], ['roman', 'typo'], ['zeitschrift', 'typo'], + ['religion', 'sakral'], ['mytholog', 'sakral'], ['klassiker', 'klassiker'], ['science', 'kosmos'], ['sci-fi', 'kosmos'], + ['fantasy', 'ornament'], ['krimi', 'schatten'], ['thriller', 'schatten'], ['warhammer', 'schatten'], ['horror', 'grusel'], + ['kinder', 'spiel'], ['jugend', 'spiel'], ]; const DEFAULT = {id: 'bauhaus', font: SANS, palettes: [['#efe9dd', '#24262a', '#d0533f', '#3f7fb0'], ['#26282d', '#efe8da', '#e3b04b', '#5b9aa8'], ['#e8ecef', '#22272c', '#c8553d', '#6b8f71'], @@ -114,13 +160,29 @@ function norm(s) { return String(s || '').toLowerCase(); } + const LETTER = 'a-z0-9äöüßàâçéèêëîïôûùÿñæœ'; + function matcher(w, atStart) { + const start = atStart || w.charAt(0) === '^'; + const whole = w.slice(-1) === '$'; + const stem = w.replace(/^\^|\$$/g, '').replace(/[.*+?()[\]{}|\\]/g, '\\$&'); + if (!start && !whole && stem.length >= 5) return s => s.indexOf(stem) >= 0; + const re = new RegExp('(^|[^' + LETTER + '])' + stem + (whole || stem.length <= 3 ? '(s|e|en)?($|[^' + LETTER + '])' : '')); + return s => re.test(s); + } + // In a name only at a word's start: "light" isn't in "Lightner". + THEMES.forEach(t => { t.match = t.words.map(w => matcher(w)); t.matchName = t.words.map(w => matcher(w, true)); }); + function scored(book) { - // The title says most about a book; author and shelf category help, a folder only tips the scales. - const fields = [[norm(book.title), 3], [norm(book.shelfTag), 2], - [norm((book.folders || (book.folder ? [book.folder] : [])).join(' ')), 1], [norm(book.author), 2]]; + // The title says most about a book; author and shelf category help. The first folder + // outweighs a word in the author or shelf category (not one in the title), further ones tip the scales. + const fields = [[norm(book.title), 3, 'match'], [norm(book.shelfTag), 2, 'match'], [norm(book.author), 2, 'matchName']]; + const folders = (book.folders || (book.folder ? [book.folder] : [])).map(norm); return THEMES.map(t => { let score = 0; - t.words.forEach(w => fields.forEach(f => { if (f[0].indexOf(w) >= 0) score += f[1]; })); + fields.forEach(f => { if (f[0]) t[f[2]].forEach(m => { if (m(f[0])) score += f[1]; }); }); + folders.forEach((f, i) => { + if (FOLDER_THEMES.some(ft => ft[1] === t.id && f.indexOf(ft[0]) >= 0)) score += i ? 1 : 2.5; + }); return {t, score}; }).filter(x => x.score > 0).sort((a, b) => b.score - a.score); } @@ -147,6 +209,29 @@ return s; }, geometrie(r, p) { + const v = r(); + if (v < 0.33) { // nested squares, turning + const cx = between(r, 110, 190), cy = between(r, 220, 320), n = 5 + Math.floor(r() * 4), turn = between(r, 6, 12); + let s = ''; + for (let i = n; i >= 1; i--) { + const d = i * between(r, 13, 17); + s += ``; + } + return s + ``; + } + if (v < 0.6) { // triangle, circle and square, drawn + const y = between(r, 250, 330), sz = between(r, 46, 60), xs = [70, 150, 230]; + const order = [0, 1, 2].sort(() => r() - 0.5), filled = Math.floor(r() * 3); + let s = ``; + order.forEach((kind, i) => { + const x = xs[i], att = i === filled ? `fill="${p[2]}" stroke="none"` : `fill="none" stroke="${p[1]}" stroke-width="1.6"`; + if (kind === 0) s += ``; + else if (kind === 1) s += ``; + else s += ``; + }); + return s + ``; + } const cx = between(r, 100, 200), cy = between(r, 170, 300); let s = ''; const n = 4 + Math.floor(r() * 5); @@ -158,6 +243,27 @@ return s; }, psyche(r, p) { + const v = r(); + if (v < 0.35) { // the Borromean knot: three rings, no two linked + const cx = between(r, 125, 175), cy = between(r, 250, 300), rr = between(r, 52, 64), d = rr * 0.62; + let s = ''; + [[-90, p[2], 0.75], [30, p[3], 0.6], [150, p[1], 0]].forEach(([a, c, o], i) => { + const x = cx + Math.cos(a * Math.PI / 180) * d, y = cy + Math.sin(a * Math.PI / 180) * d; + s += ``; + }); + return s; + } + if (v < 0.6) { // a spiral, over a divided ground + const cx = between(r, 120, 180), cy = between(r, 240, 300), turns = between(r, 3, 4.5), grow = between(r, 4.5, 6.5); + let d = ''; + for (let k = 0; k <= turns * 36; k++) { + const a = k / 36 * Math.PI * 2, rad = grow * a; + d += (k ? ' L' : 'M') + f1(cx + Math.cos(a) * rad) + ' ' + f1(cy + Math.sin(a) * rad); + } + return `` + + `` + + ``; + } let s = ``; const cy = between(r, 170, 290); const rx = between(r, 60, 95), ry = between(r, 80, 130); @@ -271,6 +377,13 @@ return s; }, schatten(r, p) { + if (r() < 0.5) { // light through a doorway, falling across the floor + const x = between(r, 90, 170), w = between(r, 44, 64), top = between(r, 150, 210), floor = between(r, 330, 360); + return `` + + `` + + `` + + ``; + } const x = between(r, 60, 240); return `` + ``; @@ -281,6 +394,98 @@ `fill="${p[2]}" opacity=".9">${esc(ch)}` + ``; }, + klassiker(r, p) { + // An all-over pattern (the paper), the plate for the text goes over it. + const kind = Math.floor(r() * 5), sz = pick(r, [18, 22, 26]); + let tile; + if (kind === 0) { // lattice of diamonds + tile = `` + + ``; + } else if (kind === 1) { // scales + tile = ``; + } else if (kind === 2) { // dots, offset rows + tile = ``; + } else if (kind === 3) { // little stars + const c = sz / 2, a = sz / 3, b = sz / 10; + tile = `` + + ``; + } else { // stripes with a beading + tile = ``; + } + return `${tile}` + + ``; + }, + sakral(r, p) { + // A pointed arch with a rose window, nested; light from above. + const cx = W / 2, w = between(r, 170, 210), ys = between(r, 300, 330); + let s = ``; + for (let i = 0; i < 3; i++) { + const ww = w - i * 34, x0 = cx - ww / 2, x1 = cx + ww / 2, apex = ys - ww * 0.866; + s += ``; + } + const ry = ys - w * 0.3, rr = between(r, 30, 42), n = pick(r, [8, 12, 16]); + s += ``; + for (let i = 0; i < n; i++) { + const a = i * Math.PI * 2 / n; + s += `` + + ``; + } + s += ``; + return s; + }, + grusel(r, p) { + // A bare tree before a big moon. + const tree = p[4] || p[1]; + const mx = between(r, 90, 210), my = between(r, 170, 250), mr = between(r, 70, 100); + let s = ``; + s += ``; + let lines = ''; + const branch = (x, y, a, len, w, d) => { + const x2 = x + Math.cos(a) * len, y2 = y + Math.sin(a) * len; + lines += ``; + if (d <= 0) return; + const spread = between(r, 0.3, 0.6); + branch(x2, y2, a - spread + between(r, -0.15, 0.15), len * between(r, 0.66, 0.8), w * 0.68, d - 1); + branch(x2, y2, a + spread + between(r, -0.15, 0.15), len * between(r, 0.66, 0.8), w * 0.68, d - 1); + }; + const tx = between(r, 70, 230); + branch(tx, H - 30, -Math.PI / 2 + between(r, -0.15, 0.15), between(r, 85, 110), 9, 6); + s += `${lines}`; + s += ``; + s += ``; + return s; + }, + spiel(r, p) { + // Rolling hills, a sun, a few bright dots. + let s = ``; + for (let i = 0; i < 3; i++) { + const cx = between(r, -40, 340), rad = between(r, 140, 220); + s += ``; + } + for (let i = 0; i < 9; i++) { + s += ``; + } + return s; + }, + code(r, p) { + // An editor's minimap: indented lines of tokens, some of them highlighted. + let s = '', indent = 0; + for (let y = 150; y < H - 24; y += 13) { + if (r() < 0.12) continue; // an empty line + indent = Math.max(0, Math.min(4, indent + pick(r, [-1, 0, 0, 1]))); + let x = 26 + indent * 16; + const n = 1 + Math.floor(r() * 3); + for (let k = 0; k < n && x < W - 30; k++) { + const w = Math.min(between(r, 14, 70), W - 26 - x), v = r(); + s += ``; + x += w + 6; + } + } + s += ``; + return s; + }, bauhaus(r, p) { let s = ''; for (let i = 0; i < 4; i++) { @@ -301,8 +506,8 @@ } // The title's lines, for a size that fits; a word too long is broken. - function lines(text, size, max) { - const per = Math.max(4, Math.floor(250 / (size * 0.56))); + function lines(text, size, max, em) { + const per = Math.max(4, Math.floor(248 / (size * (em || 0.56)))); const words = String(text).split(/\s+/).filter(Boolean); const out = []; let cur = ''; @@ -336,11 +541,13 @@ const len = t.length; const longest = t.split(/\s+/).reduce((n, w) => Math.max(n, w.length), 0); // The longest word stays whole: the type gets smaller rather than the word broken. - const size = Math.max(15, Math.min(len <= 10 ? 40 : len <= 18 ? 34 : len <= 30 ? 28 : len <= 48 ? 23 : 19, - Math.floor(250 / (longest * 0.58)))); - const ls = lines(t, size, 5); + // Capitals take more room than small letters (about .72 of the size against .56). + const em = upper ? 0.74 : 0.58; + const size = Math.max(14, Math.min(len <= 10 ? 40 : len <= 18 ? 34 : len <= 30 ? 28 : len <= 48 ? 23 : 19, + Math.floor(248 / (longest * em)))); + const ls = lines(t, size, 5, upper ? 0.72 : 0.56); const lh = size * 1.12; - const layout = pick(r, ['top', 'top', 'bottom', 'plate']); + const layout = pick(r, th.layouts || ['top', 'top', 'bottom', 'plate']); const author = String(book.author || '').split(/[;,]/)[0].trim(); const blockH = ls.length * lh + (author ? 30 : 0) + 40; let y0, s = ''; @@ -353,6 +560,7 @@ } else { y0 = 70; s += ``; + if (th.frame) s += ``; } const x = 26; ls.forEach((l, i) => { @@ -360,7 +568,10 @@ `fill="${p[1]}" letter-spacing="${upper ? 0.5 : 0}">${esc(l)}`; }); if (author) { - s += `${esc(author.toUpperCase())}`; + // A long name closes up and gets smaller rather than running off the cover. + const spacing = author.length > 22 ? 1 : 2; + const asz = Math.max(8, Math.min(12, Math.floor((240 / author.length - spacing) / 0.7))); + s += `${esc(author.toUpperCase())}`; } return s; } diff --git a/static/js/sw.js b/static/js/sw.js index a69672f..c4ec04f 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v123'; +const CACHE = 'diora-v124'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',