From e332019acb312ec64893c26cf3d797d5ce472f26 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:51:34 +0900 Subject: bluebey: ぶるべー スタジオのページを公開 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/src/gion.js | 226 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 public/bluebey-studio/src/gion.js (limited to 'public/bluebey-studio/src/gion.js') 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} items the stamps + * @param {Map} 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); +} -- cgit v1.3.1