aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/gion.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-02 23:51:34 +0900
commite332019acb312ec64893c26cf3d797d5ce472f26 (patch)
treed97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/gion.js
parent99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff)
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/gion.js')
-rw-r--r--public/bluebey-studio/src/gion.js226
1 files changed, 226 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/gion.js b/public/bluebey-studio/src/gion.js
new file mode 100644
index 0000000..832313b
--- /dev/null
+++ b/public/bluebey-studio/src/gion.js
@@ -0,0 +1,226 @@
+/**
+ * 擬音 (マンガのオノマトペ) のスタンプ.
+ *
+ * The source images are dense sheets: several sounds, each in a white-outline and
+ * a solid version, packed so tightly that an automatic slice (outline tracing /
+ * connected components) tears a single character into fragments. So nothing here
+ * guesses at a sheet's layout. Instead the user draws a rectangle over the sheet
+ * in the picker and the app crops exactly that rectangle, which means the feature
+ * works for any sheet that arrives later.
+ *
+ * That is why the crops are stored as *source pixels* (`sx, sy, sw, sh`) taken
+ * from the bitmap's own `naturalWidth` / `naturalHeight`, never as fractions of
+ * some assumed grid: the sheet's pixel size is read at runtime.
+ *
+ * The module is pure and DOM-free in the same sense as `trace.js` and
+ * `caption.js`: no globals are touched except a read of the injected
+ * `__BLUEBEY_GIONS__` lookup, and `drawGion` takes the loaded bitmaps as an
+ * argument, so the caller owns loading and the tests can drive the geometry in
+ * Node with a stub context.
+ */
+
+/**
+ * The sheets the studio offers. It ships with none: the original otarunet sheet
+ * may not be redistributed with the app, and the images the author draws later are
+ * added here. So this list starts empty and the 擬音 section stays out of the panel
+ * until an entry appears.
+ *
+ * To add one: put the image in `assets/manga-gion/`, add `{ name, label }` here
+ * (`name` is the file name *with* its extension, e.g. `dokaan.png`) and add the
+ * same `name` to `GION_NAMES` in `tools/build-standalone.mjs` for the one-file
+ * build. Use an image with a transparent background - the crop is drawn as-is, so
+ * a white background would sit on the picture as a white rectangle.
+ */
+export const GION_SHEETS = [];
+
+/**
+ * The default display width of a stamp, in pixels at 1x. A crop that is tall and
+ * thin will be narrower than this after the aspect is applied; the user resizes
+ * it from the panel either way.
+ */
+export const DEFAULT_STAMP_WIDTH = 240;
+
+/**
+ * Where a sheet's image lives. The single-file build inlines every sheet as a
+ * data URL in `window.__BLUEBEY_GIONS__`; the normal build reads the file.
+ *
+ * @param {string} name the sheet file name, with its extension, e.g. `dokaan.png`
+ * @returns {string}
+ */
+export function gionSheetUrl(name) {
+ const inlined = globalThis.__BLUEBEY_GIONS__ ?? {};
+ return inlined[name] ?? `assets/manga-gion/${name}`;
+}
+
+/** Clamp `value` into `lo..hi`; a non-finite value becomes `lo`. */
+export function clamp(value, lo, hi) {
+ if (!Number.isFinite(value)) return lo;
+ return Math.min(hi, Math.max(lo, value));
+}
+
+/** True when `image` has pixels to draw (a not-yet-loaded Image has none). */
+export function imageReady(image) {
+ if (!image) return false;
+ const width = Number(image.naturalWidth ?? image.width ?? 0);
+ const height = Number(image.naturalHeight ?? image.height ?? 0);
+ // `complete` is the browser's own answer; a stub in a test omits it, so only an
+ // explicit `false` blocks the draw.
+ return width > 0 && height > 0 && image.complete !== false;
+}
+
+/** The bitmap's width / height. 1 when it is not loaded yet. */
+export function imageAspect(image) {
+ const width = toPositive(image?.naturalWidth ?? image?.width, 1);
+ const height = toPositive(image?.naturalHeight ?? image?.height, 1);
+ return width / height;
+}
+
+/**
+ * The rectangle a stamp occupies on screen, in pixels, with rotation left out
+ * (the caller rotates about the returned centre).
+ *
+ * `item.x` / `item.y` are the centre as a fraction of the viewport, `item.w` is
+ * the width in pixels at 1x, and the height follows the bitmap's aspect ratio,
+ * so a wide crop and a tall crop are both placed by their width alone.
+ *
+ * @param {object} item a stamp: `{ x, y, w, ... }`
+ * @param {object} [viewport] `{ width, height, scale }` of the target surface
+ * @param {number} [aspect] bitmap width / height
+ * @returns {{x: number, y: number, w: number, h: number, cx: number, cy: number}}
+ */
+export function stampRect(item, { width = 0, height = 0, scale = 1 } = {}, aspect = 1) {
+ const w = toPositive(item?.w, DEFAULT_STAMP_WIDTH) * positiveScale(scale);
+ const h = w / toPositive(aspect, 1);
+ const cx = toFinite(item?.x, 0.5) * toNonNegative(width, 0);
+ const cy = toFinite(item?.y, 0.5) * toNonNegative(height, 0);
+ return { x: cx - w / 2, y: cy - h / 2, w, h, cx, cy };
+}
+
+/** Turn two corner points into a rectangle with a positive width and height. */
+export function normalizeRect(a, b) {
+ const ax = toFinite(a?.x, 0);
+ const ay = toFinite(a?.y, 0);
+ const bx = toFinite(b?.x, 0);
+ const by = toFinite(b?.y, 0);
+ return { x: Math.min(ax, bx), y: Math.min(ay, by), w: Math.abs(bx - ax), h: Math.abs(by - ay) };
+}
+
+/**
+ * Map a marquee (drawn in screen pixels over the fitted sheet) to a pixel crop of
+ * the source bitmap.
+ *
+ * `sheetRect` is where the sheet is currently displayed, so the marquee becomes a
+ * fraction of the sheet and then a fraction of the bitmap - the sheet's own pixel
+ * size comes from `natural`, never from a hard-coded number. The crop is clamped
+ * to the bitmap and is at least 1x1, so it can always be drawn.
+ *
+ * @param {{x: number, y: number, w: number, h: number}} marquee screen pixels
+ * @param {{x: number, y: number, w: number, h: number}} sheetRect screen pixels
+ * @param {object} natural the sheet image (or any `{ naturalWidth, naturalHeight }`)
+ * @returns {{sx: number, sy: number, sw: number, sh: number}}
+ */
+export function cropFromMarquee(marquee, sheetRect, natural) {
+ const nw = Math.max(1, Math.round(toPositive(natural?.naturalWidth, 1)));
+ const nh = Math.max(1, Math.round(toPositive(natural?.naturalHeight, 1)));
+ const rect = {
+ x: toFinite(marquee?.x, 0),
+ y: toFinite(marquee?.y, 0),
+ w: toNonNegative(marquee?.w, 0),
+ h: toNonNegative(marquee?.h, 0),
+ };
+ const displayW = toNonNegative(sheetRect?.w, 0);
+ const displayH = toNonNegative(sheetRect?.h, 0);
+
+ const left = displayW > 0 ? clamp((rect.x - sheetRect.x) / displayW, 0, 1) : 0;
+ const right = displayW > 0 ? clamp((rect.x + rect.w - sheetRect.x) / displayW, 0, 1) : 0;
+ const top = displayH > 0 ? clamp((rect.y - sheetRect.y) / displayH, 0, 1) : 0;
+ const bottom = displayH > 0 ? clamp((rect.y + rect.h - sheetRect.y) / displayH, 0, 1) : 0;
+
+ const sx = clamp(Math.round(left * nw), 0, nw - 1);
+ const sy = clamp(Math.round(top * nh), 0, nh - 1);
+ return {
+ sx,
+ sy,
+ sw: clamp(Math.round((right - left) * nw), 1, nw - sx),
+ sh: clamp(Math.round((bottom - top) * nh), 1, nh - sy),
+ };
+}
+
+/**
+ * Fit a bitmap inside a box, keeping its aspect ratio and centring it. The picker
+ * uses this to show a whole sheet - however large - at a size the user can drag
+ * over.
+ *
+ * @param {object} natural the sheet image
+ * @param {{width: number, height: number}} box the available area, in pixels
+ * @param {object} [options]
+ * @param {number} [options.padding=0] a margin to keep inside the box
+ * @returns {{x: number, y: number, w: number, h: number}}
+ */
+export function fitSheet(natural, box, { padding = 0 } = {}) {
+ const nw = toPositive(natural?.naturalWidth ?? natural?.width, 1);
+ const nh = toPositive(natural?.naturalHeight ?? natural?.height, 1);
+ const pad = toNonNegative(padding, 0);
+ const availW = Math.max(0, toNonNegative(box?.width, 0) - pad * 2);
+ const availH = Math.max(0, toNonNegative(box?.height, 0) - pad * 2);
+ const scale = Math.min(availW / nw, availH / nh);
+ const w = nw * scale;
+ const h = nh * scale;
+ return { x: pad + (availW - w) / 2, y: pad + (availH - h) / 2, w, h };
+}
+
+/**
+ * Paint every stamp onto a 2D context.
+ *
+ * The same function runs over the live viewport overlay (scale 1) and into the
+ * exported PNG (scale = output pixels / CSS pixels), which is what keeps the
+ * preview and the file in step. A stamp whose sheet has not loaded yet is simply
+ * skipped, so the rest of the picture is never held up by one image.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {Array<object>} items the stamps
+ * @param {Map<string, CanvasImageSource>} images loaded sheets, by name
+ * @param {object} [options]
+ * @param {number} [options.width=0]
+ * @param {number} [options.height=0]
+ * @param {number} [options.scale=1]
+ */
+export function drawGion(ctx, items, images, { width = 0, height = 0, scale = 1 } = {}) {
+ if (!ctx || !Array.isArray(items)) return;
+ const viewport = { width, height, scale: positiveScale(scale) };
+ for (const item of items) {
+ const image = images?.get?.(item?.sheet);
+ if (!imageReady(image)) continue;
+ const rect = stampRect(item, viewport, imageAspect(image));
+ if (!(rect.w > 0) || !(rect.h > 0)) continue;
+ const rot = toFinite(item?.rot, 0);
+ ctx.save();
+ ctx.translate(rect.cx, rect.cy);
+ if (rot !== 0) ctx.rotate((rot * Math.PI) / 180);
+ // The flip is applied before the draw so the same source rect feeds both.
+ if (item?.flip) ctx.scale(-1, 1);
+ ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh, -rect.w / 2, -rect.h / 2, rect.w, rect.h);
+ ctx.restore();
+ }
+}
+
+/* ----------------------------------------------------------------- numbers */
+
+function toFinite(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+function toPositive(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}
+
+function toNonNegative(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
+}
+
+function positiveScale(value) {
+ return toPositive(value, 1);
+}