aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/gion.js
blob: 832313bb2b930683db1ee90ebd2b29e48369e8b8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
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);
}