MangaParty: self-hosted manga/comic/webtoon reader
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
This commit is contained in:
commit
8dd776b7f4
29 files changed
+3499
No files matched your search
@@ -0,0 +1,215 @@
|
||||
'use strict';
|
||||
// Builds a demo library that exercises every part of the folder format (see README.md).
|
||||
// Usage: node tools/make-dummy.js [libraryDir] (default: ./library)
|
||||
// Only the demo manga folders listed below are (re)created. Nothing else in the target is touched.
|
||||
const fs = require('fs'), path = require('path');
|
||||
const { Canvas, hsl } = require('./png');
|
||||
|
||||
const OUT = path.resolve(process.argv[2] || 'library');
|
||||
const PW = 900, PH = 1300; // single page size in px
|
||||
const WHITE = [255, 255, 255];
|
||||
const SUFFIX = { C: '_cover', D: '_double', R: '_rot90', E: '_extra', S: '', W: '' };
|
||||
|
||||
// Page kinds: C chapter cover, S single, D double (tagged), W wide but untagged, R stored rotated, E extra
|
||||
const LIBRARY = [
|
||||
{
|
||||
dir: 'Moon Garden', hue: 205, type: 'MANGA',
|
||||
meta: {
|
||||
title: 'Moon Garden', altTitles: ['Tsuki no Niwa'], authors: ['Aki Tanaka'], status: 'ongoing', year: 2021,
|
||||
tags: ['Fantasy', 'Slice of Life', 'Demo'], direction: 'rtl',
|
||||
description: 'Demo manga (right to left) that uses every feature of the folder format: chapter covers, double pages, a page stored sideways, extra pages, an in-between chapter (2.5), an extra chapter, an extra book and two versions of chapter 2 from different sources.',
|
||||
sources: [
|
||||
{ name: 'MangaDex', url: 'https://mangadex.org/title/00000000-0000-0000-0000-000000000000' },
|
||||
{ name: 'Official Web', url: 'https://example.com/moon-garden' },
|
||||
],
|
||||
},
|
||||
books: [
|
||||
{ dir: 'v01 Spring', meta: { title: 'Spring' }, cover: true, chapters: [
|
||||
{ dir: 'c0001 The Seed', meta: { title: 'The Seed', source: { name: 'MangaDex', url: 'https://mangadex.org/chapter/demo-1' }, added: '2024-01-10' }, pages: 'C S S S S D S S S S S S' },
|
||||
{ dir: 'c0002 Roots [MangaDex]', meta: { title: 'Roots', source: { name: 'MangaDex', url: 'https://mangadex.org/chapter/demo-2' }, added: '2024-01-24' }, pages: 'C S S S S S S D S S S S S S' },
|
||||
{ dir: 'c0002 Roots [Official Web]', meta: { title: 'Roots', source: { name: 'Official Web', url: 'https://example.com/moon-garden/2' }, added: '2024-01-25' }, pages: 'C S S S S D S S S S S', hue: 40 },
|
||||
{ dir: 'c0002.5 Tea Time', meta: { title: 'Tea Time (omake)', added: '2024-02-01' }, pages: 'S S S S' },
|
||||
{ dir: 'x01 Character Sheets', meta: { title: 'Character Sheets', added: '2024-02-02' }, pages: 'E E E' },
|
||||
] },
|
||||
{ dir: 'v02 Summer', meta: { title: 'Summer' }, cover: true, chapters: [
|
||||
{ dir: 'c0003 The Tide', meta: { title: 'The Tide', added: '2024-03-01' }, pages: 'C S S D D S S S R S S S S E E' },
|
||||
{ dir: 'c0004 Storm', meta: { title: 'Storm', added: '2024-03-15' }, pages: 'S S S S S S S S W S S S S S S S S S' },
|
||||
{ dir: 'c0005 Harvest', meta: { title: 'Harvest', added: '2024-04-01' }, pages: 'C S S S S S S S S S' },
|
||||
] },
|
||||
{ dir: 'x01 Specials', meta: { title: 'Specials' }, chapters: [
|
||||
{ dir: 'x01 Artbook', meta: { title: 'Artbook', added: '2024-05-01' }, pages: 'C S D S S' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
dir: 'Iron Harbor', hue: 20, type: 'COMIC',
|
||||
meta: {
|
||||
title: 'Iron Harbor', authors: ['J. Smith', 'R. Lee'], status: 'completed', year: 2019, tags: ['Action', 'Demo'], direction: 'ltr',
|
||||
description: 'Demo western comic that reads left to right.',
|
||||
sources: [{ name: 'Comic Vault', url: 'https://example.com/iron-harbor' }],
|
||||
},
|
||||
books: [
|
||||
{ dir: 'v01 Book One', meta: { title: 'Book One' }, chapters: [
|
||||
{ dir: 'c0001 Arrival', meta: { title: 'Arrival', added: '2023-06-01' }, pages: 'C S S S D S S S S' },
|
||||
{ dir: 'c0002 Rust', meta: { title: 'Rust', added: '2023-06-08' }, pages: 'S S S S S S S S S S S S' },
|
||||
{ dir: 'c0003 Departure', meta: { title: 'Departure', added: '2023-06-15' }, pages: 'C S S D S S S S E' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
dir: 'Skyline Tower', hue: 280, type: 'WEBTOON',
|
||||
meta: {
|
||||
title: 'Skyline Tower', authors: ['Min-jun Park'], status: 'ongoing', year: 2023, tags: ['Drama', 'Webtoon', 'Demo'], direction: 'ttb',
|
||||
description: 'Demo webtoon: every chapter is one long vertical strip cut into slices. The numbers on the left edge are the pixel position inside the strip, so gaps between slices are easy to spot.',
|
||||
sources: [{ name: 'Webtoon Site', url: 'https://example.com/skyline' }, { name: 'Fan TL', url: 'https://example.org/skyline' }],
|
||||
},
|
||||
books: [
|
||||
{ dir: 'v01 Season 1', meta: { title: 'Season 1' }, chapters: [
|
||||
{ dir: 'c0001 Episode 1', meta: { title: 'Ground Floor', added: '2024-06-01' }, strip: [1800, 2000, 1600, 2200, 1900, 2100, 1700, 2000] },
|
||||
{ dir: 'c0002 Episode 2 [Official]', meta: { title: 'Elevator', source: { name: 'Webtoon Site', url: 'https://example.com/skyline/2' }, added: '2024-06-08', priority: 1 }, strip: Array(10).fill(1900) },
|
||||
{ dir: 'c0002 Episode 2 [Fan TL]', meta: { title: 'Elevator', source: { name: 'Fan TL', url: 'https://example.org/skyline/2' }, added: '2024-06-09' }, strip: Array(9).fill(2100), hue: 330 },
|
||||
{ dir: 'c0003 Episode 3', meta: { title: 'Rooftop', added: '2024-06-15' }, strip: [2400, 1500, 2000, 1800, 2200, 1600, 2000] },
|
||||
] },
|
||||
{ dir: 'v02 Season 2', meta: { title: 'Season 2' }, chapters: [
|
||||
{ dir: 'c0004 Episode 4', meta: { title: 'Basement', added: '2024-09-01' }, strip: [2000, 2000, 2000, 2000, 2000, 2000] },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// No manga.json / book.json / chapter.json at all: everything comes from folder names.
|
||||
dir: 'Blank Slate', hue: 120,
|
||||
books: [{ dir: 'v01', chapters: [{ dir: 'c0001', pages: 'S S S S S S' }, { dir: 'c0002', pages: 'S S S S S S' }] }],
|
||||
},
|
||||
{
|
||||
dir: 'Double Trouble', hue: 330, type: 'BOOK MODE TEST',
|
||||
meta: {
|
||||
title: 'Double Trouble', authors: ['Demo'], status: 'completed', direction: 'rtl', tags: ['Demo', 'Book mode test'],
|
||||
description: 'Book mode torture test: odd runs of single pages between double pages and back-to-back doubles.',
|
||||
},
|
||||
books: [{ dir: 'v01', chapters: [
|
||||
{ dir: 'c0001 Odd Runs', meta: { title: 'Odd Runs' }, pages: 'C S S S D S D D S S S S D S' },
|
||||
{ dir: 'c0002 All Doubles', meta: { title: 'All Doubles' }, pages: 'D D D' },
|
||||
] }],
|
||||
},
|
||||
];
|
||||
|
||||
function wrap(s, max = 10) {
|
||||
const out = [];
|
||||
for (const w of String(s || '').split(/\s+/).filter(Boolean)) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && (last + ' ' + w).length <= max) out[out.length - 1] = last + ' ' + w;
|
||||
else out.push(w);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cover(hue, lines, sub) {
|
||||
const c = new Canvas(PW, PH, hsl(hue, 0.55, 0.3));
|
||||
for (let i = 0; i < 12; i++) c.rect(0, PH * 0.64 + i * 36, PW, 14, hsl(hue + 25, 0.5, 0.2 + i * 0.012));
|
||||
c.frame(30, 30, PW - 60, PH - 60, 6, hsl(hue, 0.4, 0.75));
|
||||
let y = 150;
|
||||
for (const line of lines) {
|
||||
const size = Math.min(14, Math.floor((PW - 140) / (6 * line.length)));
|
||||
c.text(line, PW / 2, y, size, WHITE, 'center');
|
||||
y += size * 7 + 40;
|
||||
}
|
||||
c.text(sub, PW / 2, PH * 0.64 - 90, 6, hsl(hue, 0.3, 0.85), 'center');
|
||||
return c;
|
||||
}
|
||||
|
||||
function panels(n, wide, m, top, iw, ih, g) {
|
||||
const R = (fx, fy, fw, fh) => [m + fx * iw, top + fy * ih, fw * iw - g, fh * ih - g];
|
||||
if (wide) return [R(0, 0, 0.6, 1), R(0.6, 0, 0.4, 0.5), R(0.6, 0.5, 0.4, 0.5)];
|
||||
return [
|
||||
[R(0, 0, 1, 0.4), R(0, 0.4, 0.5, 0.6), R(0.5, 0.4, 0.5, 0.6)],
|
||||
[R(0, 0, 1, 1 / 3), R(0, 1 / 3, 1, 1 / 3), R(0, 2 / 3, 1, 1 / 3)],
|
||||
[R(0, 0, 0.55, 1), R(0.55, 0, 0.45, 0.5), R(0.55, 0.5, 0.45, 0.5)],
|
||||
][n % 3];
|
||||
}
|
||||
|
||||
function paper(w, n, ctx, wide) {
|
||||
const ink = hsl(ctx.hue, 0.4, 0.2), c = new Canvas(w, PH, hsl(ctx.hue, 0.3, 0.94));
|
||||
const m = 50, top = 90, g = 20;
|
||||
panels(n, wide, m, top, w - 2 * m, PH - 180, g).forEach(([x, y, pw, ph], i) => {
|
||||
c.rect(x, y, pw, ph, hsl(ctx.hue + 25 * i, 0.35, 0.84));
|
||||
c.frame(x, y, pw, ph, 7, ink);
|
||||
});
|
||||
if (wide) for (let y = top; y < PH - 90; y += 40) c.rect(w / 2 - 2, y, 4, 20, ink); // the fold
|
||||
c.text(ctx.manga, m, 34, 4, ink);
|
||||
c.text(ctx.label, w - m, 34, 4, ink, 'right');
|
||||
c.text(ctx.dir === 'ltr' ? 'READ LEFT TO RIGHT >>>' : '<<< READ RIGHT TO LEFT', w / 2, PH - 60, 4, ink, 'center');
|
||||
c.label(String(n), w / 2, PH / 2 - 120, 26, ink, WHITE);
|
||||
return c;
|
||||
}
|
||||
|
||||
function pageImage(kind, n, ctx) {
|
||||
if (kind === 'C') return cover(ctx.hue, [ctx.label, ...wrap(ctx.title)], 'CHAPTER COVER - PAGE ' + n);
|
||||
const wide = 'DWR'.includes(kind), w = wide ? PW * 2 : PW;
|
||||
const c = paper(w, n, ctx, wide);
|
||||
const band = hsl(ctx.hue, 0.5, 0.3);
|
||||
if (kind === 'D') c.label('DOUBLE PAGE', w / 2, PH / 2 + 150, 8, WHITE, band);
|
||||
if (kind === 'W') c.label('WIDE - NOT TAGGED', w / 2, PH / 2 + 150, 8, WHITE, band);
|
||||
if (kind === 'E') c.label('EXTRA PAGE', w / 2, PH / 2 + 150, 9, WHITE, [190, 40, 50]);
|
||||
if (kind === 'R') {
|
||||
c.label('STORED SIDEWAYS', w / 2, PH / 2 + 150, 8, WHITE, [30, 120, 60]);
|
||||
return c.rotateCCW(); // "_rot90" = the viewer must turn it 90 degrees clockwise
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function strip(heights, ctx) {
|
||||
const W = 800, total = heights.reduce((a, b) => a + b, 0), ink = hsl(ctx.hue, 0.5, 0.18);
|
||||
const c = new Canvas(W, total);
|
||||
for (let y = 0; y < total; y += 10) c.rect(0, y, W, 10, hsl(ctx.hue + (y / total) * 140, 0.55, 0.86 - 0.22 * Math.sin((y / total) * Math.PI)));
|
||||
for (let y = 500; y < total; y += 500) {
|
||||
c.rect(0, y, 40, 4, ink); c.rect(W - 40, y, 40, 4, ink);
|
||||
c.text(String(y), 48, y - 8, 3, ink);
|
||||
}
|
||||
for (let y = 560, s = 1; y < total - 900; s++) {
|
||||
const ph = 520 + (s % 3) * 160, x = s % 2 ? 70 : 190;
|
||||
c.rect(x + 14, y + 14, 540, ph, hsl(ctx.hue, 0.3, 0.3));
|
||||
c.rect(x, y, 540, ph, WHITE);
|
||||
c.frame(x, y, 540, ph, 8, ink);
|
||||
c.text('SCENE ' + s, x + 270, y + ph / 2 - 35, 10, ink, 'center');
|
||||
y += ph + 380;
|
||||
}
|
||||
c.label(ctx.manga, W / 2, 110, 7, ink, WHITE);
|
||||
c.text(ctx.label + ' ' + ctx.title, W / 2, 260, 5, ink, 'center');
|
||||
c.label('TO BE CONTINUED', W / 2, total - 380, 6, WHITE, ink);
|
||||
let y0 = 0;
|
||||
return heights.map(hh => { const s = c.crop(y0, hh); y0 += hh; return s; });
|
||||
}
|
||||
|
||||
const pad = n => String(n).padStart(4, '0');
|
||||
const json = (file, obj) => fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
|
||||
let files = 0;
|
||||
const png = (file, canvas) => { fs.writeFileSync(file, canvas.png()); files++; };
|
||||
|
||||
const t0 = Date.now();
|
||||
for (const m of LIBRARY) {
|
||||
const mDir = path.join(OUT, m.dir), title = (m.meta && m.meta.title) || m.dir, dir = (m.meta && m.meta.direction) || 'rtl';
|
||||
fs.rmSync(mDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(mDir, { recursive: true });
|
||||
if (m.meta) {
|
||||
json(path.join(mDir, 'manga.json'), m.meta);
|
||||
png(path.join(mDir, 'cover.png'), cover(m.hue, wrap(title), m.type));
|
||||
}
|
||||
let hue = m.hue;
|
||||
for (const b of m.books) {
|
||||
const bDir = path.join(mDir, b.dir);
|
||||
fs.mkdirSync(bDir);
|
||||
if (b.meta) json(path.join(bDir, 'book.json'), b.meta);
|
||||
if (b.cover) png(path.join(bDir, 'cover.png'), cover(m.hue + 30, [...wrap(title), b.meta.title], b.dir.split(' ')[0].toUpperCase()));
|
||||
for (const ch of b.chapters) {
|
||||
const cDir = path.join(bDir, ch.dir), k = /^(x?)[a-z]*([\d.]+)/i.exec(ch.dir);
|
||||
fs.mkdirSync(cDir);
|
||||
if (ch.meta) json(path.join(cDir, 'chapter.json'), ch.meta);
|
||||
hue += 18;
|
||||
const ctx = { manga: title, dir, hue: ch.hue !== undefined ? ch.hue : hue, label: (k[1] ? 'EXTRA ' : 'CH ') + parseFloat(k[2]), title: (ch.meta && ch.meta.title) || '' };
|
||||
if (ch.strip) strip(ch.strip, ctx).forEach((img, i) => png(path.join(cDir, pad(i + 1) + '.png'), img));
|
||||
else ch.pages.split(' ').forEach((kind, i) => png(path.join(cDir, pad(i + 1) + SUFFIX[kind] + '.png'), pageImage(kind, i + 1, ctx)));
|
||||
}
|
||||
}
|
||||
console.log('created', m.dir);
|
||||
}
|
||||
console.log(`${files} images in ${((Date.now() - t0) / 1000).toFixed(1)}s -> ${OUT}`);
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
'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 };
|
||||
Reference in new issue
Block a user