/** * 擬音 (マンガのオノマトペ) のスタンプ. * * 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); }