Generierte Cover: Ordner wählen das Motiv, neue Motive für Klassiker, Horror, Religion, Kinderbücher und Informatik
All checks were successful
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Successful in 9m3s
Test / e2e (push) Successful in 2m29s

Kurze Stichwörter treffen nur noch am Wortanfang bzw. als ganzes Wort ("gen" nicht in
"Leiden", "war" nicht in "Gegenwart"), Ordnernamen werden nicht mehr nach Wörtern
durchsucht, sondern über FOLDER_THEMES einem Motiv zugeordnet. Mehr Varianten für
Philosophie, Psychoanalyse und Krimi; Großbuchstaben und lange Autorennamen laufen
nicht mehr über den Rand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 22:44:45 +02:00
parent caeb67e886
commit f1158e32a7
3 changed files with 255 additions and 22 deletions

View file

@ -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/<id>/cover/` {cover_ct, cover_iv}`. `GET /books/` lists `has_cover`; `GET /books/<id>/cover/`
returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The
server never sees a cover. 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.

View file

@ -3,7 +3,9 @@
* device, from what the book is called and where it stands — its title, author, shelf * 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 * 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 * 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 * 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. * 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; const f1 = n => Math.round(n * 10) / 10;
// ---- the concepts --------------------------------------------------------------------------- // ---- 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. // palettes: [background, ink, accent, second accent], curated to sit well together.
const SERIF = "Georgia, 'Times New Roman', serif"; const SERIF = "Georgia, 'Times New Roman', serif";
@ -55,28 +62,29 @@
{id: 'geometrie', font: SERIF, {id: 'geometrie', font: SERIF,
words: ['philosoph', 'hegel', 'kant', 'dialekt', 'dialect', 'vernunft', 'reason', 'geist', 'spirit', 'ethik', 'ethic', words: ['philosoph', 'hegel', 'kant', 'dialekt', 'dialect', 'vernunft', 'reason', 'geist', 'spirit', 'ethik', 'ethic',
'logik', 'logic', 'metaphys', 'phänomen', 'phenomen', 'denken', 'wahrheit', 'truth', 'sein', 'nichts', 'nothing', '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'], 'kritik', 'critique', 'theorie', 'theory', 'begriff', 'wissen'],
palettes: [['#f1ece1', '#2b2a27', '#b08a3e', '#6f7d72'], ['#26292e', '#ece4d4', '#c9a25a', '#8a9aa8'], ['#e7e1d6', '#3a3833', '#8c5a3c', '#5d6b74']]}, palettes: [['#f1ece1', '#2b2a27', '#b08a3e', '#6f7d72'], ['#26292e', '#ece4d4', '#c9a25a', '#8a9aa8'], ['#e7e1d6', '#3a3833', '#8c5a3c', '#5d6b74']]},
{id: 'psyche', font: SERIF, {id: 'psyche', font: SERIF,
words: ['lacan', 'freud', 'psycho', 'sex', 'begehren', 'desire', 'disavowal', 'verleugn', 'unbewusst', 'unconscious', 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'], 'odd one', 'antigone', 'let them rot', 'klavierspielerin'],
palettes: [['#f3e3d9', '#3d2440', '#c45b6a', '#5c8a8a'], ['#2c2236', '#f2dfd2', '#e07a6f', '#7fb3ae'], ['#e8ddeb', '#2e2a3d', '#8d4f8a', '#d99a6c']]}, palettes: [['#f3e3d9', '#3d2440', '#c45b6a', '#5c8a8a'], ['#2c2236', '#f2dfd2', '#e07a6f', '#7fb3ae'], ['#e8ddeb', '#2e2a3d', '#8d4f8a', '#d99a6c']]},
{id: 'wellen', font: SERIF, {id: 'wellen', font: SERIF,
words: ['meer', 'sea', 'ozean', 'ocean', 'wasser', 'water', 'insel', 'island', 'fluss', 'river', 'flut', 'flood', 'deluge', 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']]}, palettes: [['#e5eef0', '#13324a', '#2f6f8f', '#8fb8c6'], ['#0f2a3d', '#e3edf0', '#4c95b0', '#a9d3dc'], ['#efe9dc', '#1f3b4d', '#3d7a8c', '#c98a5a']]},
{id: 'karte', font: SERIF, upper: true, {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', 'nazi', 'staat', 'state', 'nation', 'kolonial', 'colonial', 'weltkrieg', 'schlacht', 'battle', 'grenze', 'border',
'seeing like', 'world-system', 'weltsystem'], 'seeing like', 'world-system', 'weltsystem'],
palettes: [['#e9e1cc', '#3b3a2c', '#8a4b2a', '#6d7a4f'], ['#2f3127', '#e6dcc4', '#b5763c', '#8e9a6b'], ['#ddd5bd', '#2c2a22', '#7a3b2e', '#5f6f63']]}, palettes: [['#e9e1cc', '#3b3a2c', '#8a4b2a', '#6d7a4f'], ['#2f3127', '#e6dcc4', '#b5763c', '#8e9a6b'], ['#ddd5bd', '#2c2a22', '#7a3b2e', '#5f6f63']]},
{id: 'kosmos', font: SANS, upper: true, {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'], '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']]}, palettes: [['#141833', '#ece6d3', '#e3b65b', '#7d8bd4'], ['#0e1424', '#dfe6f2', '#d9824a', '#6fa3c4'], ['#1f1a33', '#efe7da', '#c9a8e0', '#e3c46b']]},
{id: 'ornament', font: SERIF, {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', 'krone', 'crown', 'schwert', 'sword', 'warrior', 'krieger', 'cats', 'katze', 'legende', 'legend', 'märchen', 'fairy',
'zauber', 'spell', 'ice and fire', 'bundle'], 'zauber', 'spell', 'ice and fire', 'bundle'],
palettes: [['#1e2a2a', '#efe3c4', '#c69a3c', '#8f3b46'], ['#2b1d2e', '#f0e2c8', '#d3a13f', '#3f7a6d'], ['#f0e6d0', '#2d2433', '#8c3a4a', '#2f6b65']]}, 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'], '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']]}, palettes: [['#eef0e4', '#23301f', '#4f7a43', '#b7a35a'], ['#203024', '#e8ecdc', '#86a86b', '#d6b56a'], ['#f2ece0', '#2b3326', '#6f8f5a', '#c27c5a']]},
{id: 'berge', font: SANS, upper: true, {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'], 'schnee', 'snow', 'winter', 'eis', 'ice', 'tal', 'valley', 'wandern'],
palettes: [['#e8ecef', '#22303b', '#5b7a8f', '#a9bccb'], ['#24323d', '#eef1f2', '#7f9fb3', '#c8d6de'], ['#efe9e0', '#2e3438', '#6f8590', '#c7a27a']]}, palettes: [['#e8ecef', '#22303b', '#5b7a8f', '#a9bccb'], ['#24323d', '#eef1f2', '#7f9fb3', '#c8d6de'], ['#efe9e0', '#2e3438', '#6f8590', '#c7a27a']]},
{id: 'zellen', font: SANS, {id: 'zellen', font: SANS,
words: ['biolog', 'gehirn', 'brain', 'mensch', 'human', 'behave', 'verhalten', 'behavio', 'sucht', 'addict', 'enough', 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']]}, palettes: [['#f1ebe4', '#26323a', '#e0765c', '#4f9a94'], ['#1f2b31', '#eef0ea', '#f08a6c', '#69b3aa'], ['#eaf0ee', '#22303a', '#d4634e', '#3f8f8a']]},
{id: 'raster', font: SANS, {id: 'raster', font: SANS,
words: ['stadt', 'city', 'urban', 'risiko', 'risk', 'gesellschaft', 'society', 'moderne', 'modern', 'system', 'analys', 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']]}, palettes: [['#ecebe7', '#1f2328', '#d1453b', '#2f5f8f'], ['#22262b', '#ebe8e1', '#e2b33c', '#4f86b8'], ['#f0ede6', '#2a2d31', '#3f6fa0', '#d9a03a']]},
{id: 'schatten', font: NARROW, upper: true, {id: 'schatten', font: NARROW, upper: true,
words: ['tod', 'death', 'mord', 'murder', 'crime', 'verbrechen', 'schatten', 'shadow', 'dunkel', 'dark', 'angst', 'fear', 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']]}, palettes: [['#151515', '#ecebe6', '#c2302f', '#5a5a5a'], ['#ecebe6', '#151515', '#a8241f', '#7c7c7c'], ['#1c1f24', '#e4e0d8', '#d0a04a', '#55606b']]},
{id: 'typo', font: SERIF, {id: 'typo', font: SERIF,
words: ['roman', 'novel', 'erzählung', 'stories', 'geschichten', 'gedicht', 'poem', 'poesie', 'poetry', 'brief', 'letters', words: ['roman', 'novel', 'erzählung', 'stories', 'geschichten', 'gedicht', 'poem', 'poesie', 'poetry', 'brief', 'letters',
'tagebuch', 'diary', 'molloy', 'beckett', 'manuskripte', 'literatur', 'literature', 'review', 'zeitschrift'], 'tagebuch', 'diary', 'molloy', 'beckett', 'manuskripte', 'literatur', 'literature', 'review', 'zeitschrift'],
palettes: [['#f2ede3', '#211f1c', '#9b3b2f', '#7d7466'], ['#24211e', '#f0e9dc', '#d39b5f', '#8f8578'], ['#e9e4f0', '#26222e', '#5b4a8a', '#a3909a']]}, 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, const DEFAULT = {id: 'bauhaus', font: SANS,
palettes: [['#efe9dd', '#24262a', '#d0533f', '#3f7fb0'], ['#26282d', '#efe8da', '#e3b04b', '#5b9aa8'], ['#e8ecef', '#22272c', '#c8553d', '#6b8f71'], palettes: [['#efe9dd', '#24262a', '#d0533f', '#3f7fb0'], ['#26282d', '#efe8da', '#e3b04b', '#5b9aa8'], ['#e8ecef', '#22272c', '#c8553d', '#6b8f71'],
@ -114,13 +160,29 @@
function norm(s) { return String(s || '').toLowerCase(); } 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) { function scored(book) {
// The title says most about a book; author and shelf category help, a folder only tips the scales. // The title says most about a book; author and shelf category help. The first folder
const fields = [[norm(book.title), 3], [norm(book.shelfTag), 2], // outweighs a word in the author or shelf category (not one in the title), further ones tip the scales.
[norm((book.folders || (book.folder ? [book.folder] : [])).join(' ')), 1], [norm(book.author), 2]]; 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 => { return THEMES.map(t => {
let score = 0; 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}; return {t, score};
}).filter(x => x.score > 0).sort((a, b) => b.score - a.score); }).filter(x => x.score > 0).sort((a, b) => b.score - a.score);
} }
@ -147,6 +209,29 @@
return s; return s;
}, },
geometrie(r, p) { 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 += `<rect x="${f1(cx - d)}" y="${f1(cy - d)}" width="${f1(d * 2)}" height="${f1(d * 2)}" fill="none" stroke="${i === 2 ? p[2] : p[1]}" ` +
`stroke-width="${i === 2 ? 2.2 : 1}" opacity="${i === 2 ? 0.9 : 0.5}" transform="rotate(${f1(i * turn)} ${f1(cx)} ${f1(cy)})"/>`;
}
return s + `<circle cx="${f1(cx)}" cy="${f1(cy)}" r="${f1(between(r, 6, 11))}" fill="${p[2]}"/>`;
}
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 = `<line x1="20" y1="${f1(y + sz + 14)}" x2="${W - 20}" y2="${f1(y + sz + 14)}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`;
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 += `<circle cx="${x}" cy="${f1(y + sz / 2)}" r="${f1(sz / 2)}" ${att}/>`;
else if (kind === 1) s += `<rect x="${f1(x - sz / 2)}" y="${f1(y)}" width="${f1(sz)}" height="${f1(sz)}" ${att}/>`;
else s += `<path d="M${x} ${f1(y)} L${f1(x + sz / 2)} ${f1(y + sz)} L${f1(x - sz / 2)} ${f1(y + sz)} Z" ${att}/>`;
});
return s + `<circle cx="${f1(between(r, 40, 260))}" cy="${f1(y - between(r, 40, 80))}" r="3" fill="${p[3]}"/>`;
}
const cx = between(r, 100, 200), cy = between(r, 170, 300); const cx = between(r, 100, 200), cy = between(r, 170, 300);
let s = ''; let s = '';
const n = 4 + Math.floor(r() * 5); const n = 4 + Math.floor(r() * 5);
@ -158,6 +243,27 @@
return s; return s;
}, },
psyche(r, p) { 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 += `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(rr)}" fill="${c}" fill-opacity="${o}" stroke="${p[1]}" stroke-width="${i === 2 ? 2.4 : 1.2}"/>`;
});
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 `<rect x="0" y="${f1(cy)}" width="${W}" height="${f1(H - cy)}" fill="${p[3]}" opacity=".3"/>` +
`<path d="${d}" fill="none" stroke="${p[2]}" stroke-width="2.4" stroke-linecap="round"/>` +
`<circle cx="${f1(cx)}" cy="${f1(cy)}" r="4" fill="${p[1]}"/>`;
}
let s = `<rect x="${W / 2}" y="0" width="${W / 2}" height="${H}" fill="${p[3]}" opacity=".35"/>`; let s = `<rect x="${W / 2}" y="0" width="${W / 2}" height="${H}" fill="${p[3]}" opacity=".35"/>`;
const cy = between(r, 170, 290); const cy = between(r, 170, 290);
const rx = between(r, 60, 95), ry = between(r, 80, 130); const rx = between(r, 60, 95), ry = between(r, 80, 130);
@ -271,6 +377,13 @@
return s; return s;
}, },
schatten(r, p) { 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 `<rect x="0" y="${f1(floor)}" width="${W}" height="${f1(H - floor)}" fill="${p[3]}" opacity=".25"/>` +
`<rect x="${f1(x)}" y="${f1(top)}" width="${f1(w)}" height="${f1(floor - top)}" fill="${p[1]}" opacity=".9"/>` +
`<path d="M${f1(x)} ${f1(floor)} L${f1(x + w)} ${f1(floor)} L${f1(x + w + between(r, 60, 120))} ${H} L${f1(x - between(r, 10, 40))} ${H} Z" fill="${p[1]}" opacity=".35"/>` +
`<rect x="${f1(x + w * 0.3)}" y="${f1(floor - between(r, 60, 80))}" width="${f1(w * 0.18)}" height="${f1(between(r, 60, 80))}" fill="${p[2]}"/>`;
}
const x = between(r, 60, 240); const x = between(r, 60, 240);
return `<rect x="${f1(x)}" y="0" width="${f1(between(r, 3, 8))}" height="${H}" fill="${p[2]}"/>` + return `<rect x="${f1(x)}" y="0" width="${f1(between(r, 3, 8))}" height="${H}" fill="${p[2]}"/>` +
`<circle cx="${f1(x)}" cy="${f1(between(r, 200, 330))}" r="${f1(between(r, 50, 90))}" fill="${p[3]}" opacity=".35"/>`; `<circle cx="${f1(x)}" cy="${f1(between(r, 200, 330))}" r="${f1(between(r, 50, 90))}" fill="${p[3]}" opacity=".35"/>`;
@ -281,6 +394,98 @@
`fill="${p[2]}" opacity=".9">${esc(ch)}</text>` + `fill="${p[2]}" opacity=".9">${esc(ch)}</text>` +
`<line x1="20" y1="${f1(between(r, 120, 160))}" x2="${W - 20}" y2="${f1(between(r, 120, 160))}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`; `<line x1="20" y1="${f1(between(r, 120, 160))}" x2="${W - 20}" y2="${f1(between(r, 120, 160))}" stroke="${p[1]}" stroke-width="1" opacity=".5"/>`;
}, },
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 = `<path d="M0 ${sz / 2} L${sz / 2} 0 L${sz} ${sz / 2} L${sz / 2} ${sz} Z" fill="none" stroke="${p[2]}" stroke-width="1.4"/>` +
`<circle cx="${sz / 2}" cy="${sz / 2}" r="${f1(sz / 9)}" fill="${p[3]}"/>`;
} else if (kind === 1) { // scales
tile = `<path d="M0 ${sz} A${sz / 2} ${sz / 2} 0 0 1 ${sz} ${sz} M${-sz / 2} ${sz / 2} A${sz / 2} ${sz / 2} 0 0 1 ${sz / 2} ${sz / 2} ` +
`M${sz / 2} ${sz / 2} A${sz / 2} ${sz / 2} 0 0 1 ${sz * 1.5} ${sz / 2}" fill="none" stroke="${p[2]}" stroke-width="1.3"/>`;
} else if (kind === 2) { // dots, offset rows
tile = `<circle cx="${sz / 4}" cy="${sz / 4}" r="${f1(sz / 7)}" fill="${p[2]}"/><circle cx="${sz * 0.75}" cy="${sz * 0.75}" r="${f1(sz / 7)}" fill="${p[3]}"/>`;
} else if (kind === 3) { // little stars
const c = sz / 2, a = sz / 3, b = sz / 10;
tile = `<path d="M${c} ${f1(c - a)} L${f1(c + b)} ${f1(c - b)} L${f1(c + a)} ${c} L${f1(c + b)} ${f1(c + b)} L${c} ${f1(c + a)} L${f1(c - b)} ${f1(c + b)} L${f1(c - a)} ${c} L${f1(c - b)} ${f1(c - b)} Z" fill="${p[2]}"/>` +
`<circle cx="0" cy="0" r="1.6" fill="${p[3]}"/><circle cx="${sz}" cy="0" r="1.6" fill="${p[3]}"/><circle cx="0" cy="${sz}" r="1.6" fill="${p[3]}"/><circle cx="${sz}" cy="${sz}" r="1.6" fill="${p[3]}"/>`;
} else { // stripes with a beading
tile = `<rect x="0" y="0" width="${f1(sz / 3)}" height="${sz}" fill="${p[2]}" opacity=".8"/><circle cx="${f1(sz * 0.67)}" cy="${sz / 2}" r="${f1(sz / 9)}" fill="${p[3]}"/>`;
}
return `<defs><pattern id="pt" width="${sz}" height="${sz}" patternUnits="userSpaceOnUse">${tile}</pattern></defs>` +
`<rect width="${W}" height="${H}" fill="url(#pt)" opacity=".75"/>`;
},
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 = `<rect width="${W}" height="${H}" fill="${p[3]}" opacity=".12"/>`;
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 += `<path d="M${f1(x0)} ${H} L${f1(x0)} ${f1(ys)} A${f1(ww)} ${f1(ww)} 0 0 1 ${f1(cx)} ${f1(apex)} A${f1(ww)} ${f1(ww)} 0 0 1 ${f1(x1)} ${f1(ys)} L${f1(x1)} ${H}" ` +
`fill="${i === 2 ? p[2] : 'none'}" fill-opacity="${i === 2 ? 0.18 : 0}" stroke="${p[2]}" stroke-width="${i ? 1.2 : 2.4}" opacity="${i ? 0.8 : 1}"/>`;
}
const ry = ys - w * 0.3, rr = between(r, 30, 42), n = pick(r, [8, 12, 16]);
s += `<circle cx="${cx}" cy="${f1(ry)}" r="${f1(rr)}" fill="none" stroke="${p[2]}" stroke-width="1.6"/>`;
for (let i = 0; i < n; i++) {
const a = i * Math.PI * 2 / n;
s += `<line x1="${cx}" y1="${f1(ry)}" x2="${f1(cx + Math.cos(a) * rr)}" y2="${f1(ry + Math.sin(a) * rr)}" stroke="${p[2]}" stroke-width="1"/>` +
`<circle cx="${f1(cx + Math.cos(a) * rr * 0.62)}" cy="${f1(ry + Math.sin(a) * rr * 0.62)}" r="${f1(rr * 0.16)}" fill="${i % 2 ? p[3] : p[2]}" opacity=".85"/>`;
}
s += `<line x1="${cx}" y1="${f1(ry + rr)}" x2="${cx}" y2="${H}" stroke="${p[2]}" stroke-width="1.2" opacity=".8"/>`;
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 = `<circle cx="${f1(mx)}" cy="${f1(my)}" r="${f1(mr)}" fill="${p[3]}" opacity=".9"/>`;
s += `<circle cx="${f1(mx + mr * 0.3)}" cy="${f1(my - mr * 0.2)}" r="${f1(mr * 0.18)}" fill="${p[0]}" opacity=".08"/>`;
let lines = '';
const branch = (x, y, a, len, w, d) => {
const x2 = x + Math.cos(a) * len, y2 = y + Math.sin(a) * len;
lines += `<line x1="${f1(x)}" y1="${f1(y)}" x2="${f1(x2)}" y2="${f1(y2)}" stroke-width="${f1(w)}"/>`;
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 += `<g stroke="${tree}" stroke-linecap="round">${lines}</g>`;
s += `<path d="M0 ${H} L0 ${H - 34} Q${W / 2} ${f1(H - between(r, 50, 70))} ${W} ${H - 30} L${W} ${H} Z" fill="${tree}"/>`;
s += `<rect x="0" y="${H - 8}" width="${W}" height="8" fill="${p[2]}"/>`;
return s;
},
spiel(r, p) {
// Rolling hills, a sun, a few bright dots.
let s = `<circle cx="${f1(between(r, 70, 230))}" cy="${f1(between(r, 180, 240))}" r="${f1(between(r, 34, 50))}" fill="${p[2]}"/>`;
for (let i = 0; i < 3; i++) {
const cx = between(r, -40, 340), rad = between(r, 140, 220);
s += `<circle cx="${f1(cx)}" cy="${f1(H + rad * 0.45 - i * 30)}" r="${f1(rad)}" fill="${i % 2 ? p[3] : p[1]}" opacity="${f1(0.18 + i * 0.22)}"/>`;
}
for (let i = 0; i < 9; i++) {
s += `<circle cx="${f1(r() * W)}" cy="${f1(between(r, 140, 320))}" r="${f1(between(r, 3, 8))}" fill="${pick(r, [p[2], p[3]])}" opacity=".85"/>`;
}
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 += `<rect x="${f1(x)}" y="${y}" width="${f1(w)}" height="6" rx="3" fill="${v < 0.2 ? p[2] : v < 0.35 ? p[3] : p[1]}" opacity="${v < 0.35 ? 0.95 : 0.3}"/>`;
x += w + 6;
}
}
s += `<rect x="12" y="140" width="3" height="${H - 160}" fill="${p[2]}" opacity=".7"/>`;
return s;
},
bauhaus(r, p) { bauhaus(r, p) {
let s = ''; let s = '';
for (let i = 0; i < 4; i++) { 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. // The title's lines, for a size that fits; a word too long is broken.
function lines(text, size, max) { function lines(text, size, max, em) {
const per = Math.max(4, Math.floor(250 / (size * 0.56))); const per = Math.max(4, Math.floor(248 / (size * (em || 0.56))));
const words = String(text).split(/\s+/).filter(Boolean); const words = String(text).split(/\s+/).filter(Boolean);
const out = []; const out = [];
let cur = ''; let cur = '';
@ -336,11 +541,13 @@
const len = t.length; const len = t.length;
const longest = t.split(/\s+/).reduce((n, w) => Math.max(n, w.length), 0); 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. // 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, // Capitals take more room than small letters (about .72 of the size against .56).
Math.floor(250 / (longest * 0.58)))); const em = upper ? 0.74 : 0.58;
const ls = lines(t, size, 5); 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 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 author = String(book.author || '').split(/[;,]/)[0].trim();
const blockH = ls.length * lh + (author ? 30 : 0) + 40; const blockH = ls.length * lh + (author ? 30 : 0) + 40;
let y0, s = ''; let y0, s = '';
@ -353,6 +560,7 @@
} else { } else {
y0 = 70; y0 = 70;
s += `<rect x="18" y="${f1(y0 - 34)}" width="${W - 36}" height="${f1(blockH)}" rx="3" fill="${p[0]}" opacity=".95"/>`; s += `<rect x="18" y="${f1(y0 - 34)}" width="${W - 36}" height="${f1(blockH)}" rx="3" fill="${p[0]}" opacity=".95"/>`;
if (th.frame) s += `<rect x="24" y="${f1(y0 - 28)}" width="${W - 48}" height="${f1(blockH - 12)}" rx="2" fill="none" stroke="${p[1]}" stroke-width="1" opacity=".6"/>`;
} }
const x = 26; const x = 26;
ls.forEach((l, i) => { ls.forEach((l, i) => {
@ -360,7 +568,10 @@
`fill="${p[1]}" letter-spacing="${upper ? 0.5 : 0}">${esc(l)}</text>`; `fill="${p[1]}" letter-spacing="${upper ? 0.5 : 0}">${esc(l)}</text>`;
}); });
if (author) { if (author) {
s += `<text x="${x}" y="${f1(y0 + ls.length * lh + 22)}" font-family="${SANS}" font-size="12" letter-spacing="2" fill="${p[2]}">${esc(author.toUpperCase())}</text>`; // 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 += `<text x="${x}" y="${f1(y0 + ls.length * lh + 22)}" font-family="${SANS}" font-size="${asz}" letter-spacing="${spacing}" fill="${p[2]}">${esc(author.toUpperCase())}</text>`;
} }
return s; return s;
} }

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v123'; const CACHE = 'diora-v124';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',