Zero-dependency Node server + plain HTML/JS frontend. - Library is a folder tree (Manga/Book/Chapter/pages), indexed in place; page roles (cover, double, extra, rotation) are file name tags - Accounts with per-user progress, reading settings, follows and new-chapter notifications (in-app + optional webhook) - Reader with Flow, Page and Book modes, pointer-anchored page zoom, tap zones, auto-hiding controls, chapter picker, page preloading - Multiple versions per chapter with community votes - Docker image and compose file - Demo library generator (tools/make-dummy.js) - Docs: architecture, library format, import, scraper guide
115 lines
6.0 KiB
JavaScript
115 lines
6.0 KiB
JavaScript
'use strict';
|
|
// Minimal RGB canvas + PNG encoder + 5x7 bitmap font. Only used to generate the demo library.
|
|
const zlib = require('zlib');
|
|
|
|
// prettier-ignore
|
|
const FONT = {
|
|
A: '01110 10001 10001 11111 10001 10001 10001', B: '11110 10001 10001 11110 10001 10001 11110',
|
|
C: '01110 10001 10000 10000 10000 10001 01110', D: '11110 10001 10001 10001 10001 10001 11110',
|
|
E: '11111 10000 10000 11110 10000 10000 11111', F: '11111 10000 10000 11110 10000 10000 10000',
|
|
G: '01110 10001 10000 10111 10001 10001 01111', H: '10001 10001 10001 11111 10001 10001 10001',
|
|
I: '01110 00100 00100 00100 00100 00100 01110', J: '00111 00010 00010 00010 00010 10010 01100',
|
|
K: '10001 10010 10100 11000 10100 10010 10001', L: '10000 10000 10000 10000 10000 10000 11111',
|
|
M: '10001 11011 10101 10101 10001 10001 10001', N: '10001 10001 11001 10101 10011 10001 10001',
|
|
O: '01110 10001 10001 10001 10001 10001 01110', P: '11110 10001 10001 11110 10000 10000 10000',
|
|
Q: '01110 10001 10001 10001 10101 10010 01101', R: '11110 10001 10001 11110 10100 10010 10001',
|
|
S: '01111 10000 10000 01110 00001 00001 11110', T: '11111 00100 00100 00100 00100 00100 00100',
|
|
U: '10001 10001 10001 10001 10001 10001 01110', V: '10001 10001 10001 10001 10001 01010 00100',
|
|
W: '10001 10001 10001 10101 10101 10101 01010', X: '10001 10001 01010 00100 01010 10001 10001',
|
|
Y: '10001 10001 01010 00100 00100 00100 00100', Z: '11111 00001 00010 00100 01000 10000 11111',
|
|
0: '01110 10001 10011 10101 11001 10001 01110', 1: '00100 01100 00100 00100 00100 00100 01110',
|
|
2: '01110 10001 00001 00010 00100 01000 11111', 3: '11111 00010 00100 00010 00001 10001 01110',
|
|
4: '00010 00110 01010 10010 11111 00010 00010', 5: '11111 10000 11110 00001 00001 10001 01110',
|
|
6: '00110 01000 10000 11110 10001 10001 01110', 7: '11111 00001 00010 00100 01000 01000 01000',
|
|
8: '01110 10001 10001 01110 10001 10001 01110', 9: '01110 10001 10001 01111 00001 00010 01100',
|
|
'.': '00000 00000 00000 00000 00000 01100 01100', '-': '00000 00000 00000 11111 00000 00000 00000',
|
|
':': '00000 01100 01100 00000 01100 01100 00000', '/': '00000 00001 00010 00100 01000 10000 00000',
|
|
'#': '01010 01010 11111 01010 11111 01010 01010', '<': '00010 00100 01000 10000 01000 00100 00010',
|
|
'>': '01000 00100 00010 00001 00010 00100 01000', '(': '00010 00100 01000 01000 01000 00100 00010',
|
|
')': '01000 00100 00010 00010 00010 00100 01000', '!': '00100 00100 00100 00100 00100 00000 00100',
|
|
'?': '01110 10001 00001 00010 00100 00000 00100', ',': '00000 00000 00000 00000 01100 00100 01000',
|
|
"'": '00100 00100 01000 00000 00000 00000 00000', '&': '01100 10010 10100 01000 10101 10010 01101',
|
|
'+': '00000 00100 00100 11111 00100 00100 00000', '=': '00000 00000 11111 00000 11111 00000 00000',
|
|
};
|
|
|
|
const CRC = new Int32Array(256).map((_, n) => {
|
|
for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
|
|
return n;
|
|
});
|
|
function crc32(buf) {
|
|
let c = -1;
|
|
for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8);
|
|
return (c ^ -1) >>> 0;
|
|
}
|
|
function chunk(type, data) {
|
|
const head = Buffer.alloc(4), crc = Buffer.alloc(4), body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
head.writeUInt32BE(data.length);
|
|
crc.writeUInt32BE(crc32(body));
|
|
return Buffer.concat([head, body, crc]);
|
|
}
|
|
|
|
function hsl(h, s, l) {
|
|
h = (((h % 360) + 360) % 360) / 30;
|
|
const a = s * Math.min(l, 1 - l);
|
|
const f = n => { const k = (n + h) % 12; return Math.round(255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)))); };
|
|
return [f(0), f(8), f(4)];
|
|
}
|
|
|
|
class Canvas {
|
|
constructor(w, h, bg) {
|
|
this.w = w; this.h = h; this.px = Buffer.alloc(w * h * 3);
|
|
if (bg) this.rect(0, 0, w, h, bg);
|
|
}
|
|
rect(x, y, w, h, color) {
|
|
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
|
|
const x1 = Math.min(this.w, Math.round(x + w)), y1 = Math.min(this.h, Math.round(y + h));
|
|
if (x1 <= x0 || y1 <= y0) return;
|
|
const pat = Buffer.from(color);
|
|
for (let r = y0; r < y1; r++) this.px.fill(pat, (r * this.w + x0) * 3, (r * this.w + x1) * 3);
|
|
}
|
|
frame(x, y, w, h, t, color) {
|
|
this.rect(x, y, w, t, color); this.rect(x, y + h - t, w, t, color);
|
|
this.rect(x, y, t, h, color); this.rect(x + w - t, y, t, h, color);
|
|
}
|
|
textWidth(s, size) { return String(s).length * 6 * size - size; }
|
|
text(s, x, y, size, color, align) {
|
|
s = String(s).toUpperCase();
|
|
if (align === 'center') x -= this.textWidth(s, size) / 2;
|
|
if (align === 'right') x -= this.textWidth(s, size);
|
|
for (const ch of s) {
|
|
const g = FONT[ch];
|
|
if (g) g.split(' ').forEach((row, ry) => {
|
|
for (let rx = 0; rx < 5; rx++) if (row[rx] === '1') this.rect(x + rx * size, y + ry * size, size, size, color);
|
|
});
|
|
x += 6 * size;
|
|
}
|
|
}
|
|
// Text on a padded box, handy for labels that must stay readable on any background.
|
|
label(s, x, y, size, color, box, align = 'center') {
|
|
const w = this.textWidth(s, size), pad = size * 2;
|
|
const left = align === 'center' ? x - w / 2 : align === 'right' ? x - w : x;
|
|
this.rect(left - pad, y - pad, w + 2 * pad, 7 * size + 2 * pad, box);
|
|
this.text(s, left, y, size, color);
|
|
}
|
|
crop(y, h) {
|
|
const c = new Canvas(this.w, h);
|
|
this.px.copy(c.px, 0, y * this.w * 3, (y + h) * this.w * 3);
|
|
return c;
|
|
}
|
|
// Rotate 90° counter-clockwise (so a viewer must rotate it 90° clockwise to see it upright).
|
|
rotateCCW() {
|
|
const c = new Canvas(this.h, this.w);
|
|
for (let y = 0; y < this.h; y++)
|
|
for (let x = 0; x < this.w; x++) this.px.copy(c.px, ((this.w - 1 - x) * c.w + y) * 3, (y * this.w + x) * 3, (y * this.w + x) * 3 + 3);
|
|
return c;
|
|
}
|
|
png() {
|
|
const ihdr = Buffer.alloc(13), stride = this.w * 3, raw = Buffer.alloc((stride + 1) * this.h);
|
|
ihdr.writeUInt32BE(this.w, 0); ihdr.writeUInt32BE(this.h, 4); ihdr[8] = 8; ihdr[9] = 2; // 8 bit RGB
|
|
for (let y = 0; y < this.h; y++) this.px.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
|
}
|
|
}
|
|
|
|
module.exports = { Canvas, hsl };
|