PageSourceSearch

https://batchimagecrop.com/scripts/silhouette-calibrate.js

js batchimagecrop.com collected 2026-10-04 11:13:01 UTC 21,199 bytes, 513 lines download raw bytes

1import { state, updateCropOption } from './app.js';
2import { getIdPhotoPreset, setIdPhotoGuideOverride, buildIdPhotoGuideFromPreset } from './id-photo-presets.js';
3
4const DEFAULT_SOURCE_HEAD_WIDTH_RATIO = 0.48;
5const DEFAULT_OFFSET_Y = -0.01;
6
7let calibrateSnapshot = null;
8let draggingSilhouette = null;
9let dragStart = { x: 0, y: 0 };
10let dragStartParams = null;
11let dragMeta = null;
12let activePointerId = null;
13let handlersAttached = false;
14
15function roundParam(value, digits = 4) {
16  const n = Number(value);
17  if (!Number.isFinite(n)) return 0;
18  const factor = 10 ** digits;
19  return Math.round(n * factor) / factor;
20}
21
22function ensureIdPhotoGuideBase() {
23  const current = state.cropOptions.idPhotoGuide;
24  if (current?.head && current?.face) return current;
25  const built = buildIdPhotoGuideFromPreset(getIdPhotoPreset(state.cropOptions.idPhotoPreset));
26  if (!built) return current || { hideDefaultGrid: true, silhouette: {} };
27  if (!current) return built;
28  return {
29    ...built,
30    ...current,
31    head: current.head || built.head,
32    face: current.face || built.face,
33    lines: Array.isArray(current.lines) && current.lines.length ? current.lines : built.lines,
34    silhouette: {
35      ...(built.silhouette || {}),
36      ...(current.silhouette && typeof current.silhouette === 'object' ? current.silhouette : {})
37    }
38  };
39}
40
41export function isSilhouetteCalibrateMode() {
42  return state.cropOptions.mode === 'idphoto' && state.cropOptions.idPhotoSilhouetteCalibrate === true;
43}
44
45export function getActiveSilhouetteParams() {
46  const guide = ensureIdPhotoGuideBase();
47  const silhouette = guide?.silhouette && typeof guide.silhouette === 'object' ? guide.silhouette : {};
48  const presetGuide = getIdPhotoPreset(state.cropOptions.idPhotoPreset)?.guide || {};
49
50  return {
51    scale: Number.isFinite(silhouette.scale)
52      ? silhouette.scale
53      : (Number.isFinite(presetGuide.silhouetteScale) ? presetGuide.silhouetteScale : 1),
54    heightScale: Number.isFinite(silhouette.heightScale)
55      ? silhouette.heightScale
56      : (Number.isFinite(presetGuide.silhouetteHeightScale) ? presetGuide.silhouetteHeightScale : 1),
57    offsetXRatio: Number.isFinite(silhouette.offsetXRatio)
58      ? silhouette.offsetXRatio
59      : (Number.isFinite(presetGuide.silhouetteOffsetXRatio) ? presetGuide.silhouetteOffsetXRatio : 0),
60    offsetYRatio: Number.isFinite(silhouette.offsetYRatio)
61      ? silhouette.offsetYRatio
62      : (Number.isFinite(presetGuide.silhouetteOffsetYRatio) ? presetGuide.silhouetteOffsetYRatio : DEFAULT_OFFSET_Y),
63    sourceHeadWidthRatio: Number.isFinite(silhouette.sourceHeadWidthRatio)
64      ? silhouette.sourceHeadWidthRatio
65      : (Number.isFinite(presetGuide.silhouetteSourceHeadWidthRatio)
66        ? presetGuide.silhouetteSourceHeadWidthRatio
67        : DEFAULT_SOURCE_HEAD_WIDTH_RATIO)
68  };
69}
70
71export function formatSilhouetteParams(params = getActiveSilhouetteParams()) {
72  return {
73    scale: roundParam(params.scale),
74    heightScale: roundParam(params.heightScale),
75    offsetXRatio: roundParam(params.offsetXRatio),
76    offsetYRatio: roundParam(params.offsetYRatio),
77    sourceHeadWidthRatio: roundParam(params.sourceHeadWidthRatio)
78  };
79}
80
81function cloneGuide(guide) {
82  return guide ? JSON.parse(JSON.stringify(guide)) : null;
83}
84
85export function applySilhouetteParams(partial, { render = true } = {}) {
86  const currentGuide = ensureIdPhotoGuideBase();
87  const current = getActiveSilhouetteParams();
88  const nextParams = {
89    ...current,
90    ...partial,
91    scale: Math.max(0.2, Number(partial.scale ?? current.scale) || current.scale),
92    heightScale: Math.max(0.5, Number(partial.heightScale ?? current.heightScale) || current.heightScale),
93    offsetXRatio: Number(partial.offsetXRatio ?? current.offsetXRatio) || 0,
94    offsetYRatio: Number.isFinite(Number(partial.offsetYRatio ?? current.offsetYRatio))
95      ? Number(partial.offsetYRatio ?? current.offsetYRatio)
96      : current.offsetYRatio,
97    sourceHeadWidthRatio: Math.max(
98      0.1,
99      Number(partial.sourceHeadWidthRatio ?? current.sourceHeadWidthRatio) || current.sourceHeadWidthRatio
100    )
101  };
102
103  const nextGuide = {
104    ...currentGuide,
105    hideDefaultGrid: true,
106    silhouette: {
107      ...(currentGuide.silhouette && typeof currentGuide.silhouette === 'object' ? currentGuide.silhouette : {}),
108      enabled: true,
109      scale: nextParams.scale,
110      heightScale: nextParams.heightScale,
111      offsetXRatio: nextParams.offsetXRatio,
112      offsetYRatio: nextParams.offsetYRatio,
113      sourceHeadWidthRatio: nextParams.sourceHeadWidthRatio
114    }
115  };
116
117  updateCropOption('idPhotoGuide', nextGuide);
118  syncCalibrateReadout();
119  if (render) {
120    import('./grid.js').then((grid) => grid.renderGrid());
121  }
122  return formatSilhouetteParams(nextParams);
123}
124
125export function enableSilhouetteCalibrate() {
126  if (state.cropOptions.mode !== 'idphoto') return false;
127  const guide = ensureIdPhotoGuideBase();
128  updateCropOption('idPhotoGuide', guide);
129  calibrateSnapshot = {
130    guide: cloneGuide(guide),
131    params: getActiveSilhouetteParams()
132  };
133  updateCropOption('idPhotoSilhouetteCalibrate', true);
134  syncCalibratePanel();
135  import('./grid.js').then((grid) => grid.renderGrid());
136  return true;
137}
138
139export function cancelSilhouetteCalibrate() {
140  if (calibrateSnapshot?.guide) {
141    updateCropOption('idPhotoGuide', calibrateSnapshot.guide);
142  }
143  calibrateSnapshot = null;
144  updateCropOption('idPhotoSilhouetteCalibrate', false);
145  syncCalibratePanel();
146  import('./grid.js').then((grid) => grid.renderGrid());
147}
148
149export async function confirmSilhouetteCalibrate() {
150  const params = formatSilhouetteParams();
151  const presetKey = state.cropOptions.idPhotoPreset;
152  const payload = {
153    presetKey,
154    silhouetteScale: params.scale,
155    silhouetteHeightScale: params.heightScale,
156    silhouetteOffsetXRatio: params.offsetXRatio,
157    silhouetteOffsetYRatio: params.offsetYRatio,
158    silhouetteSourceHeadWidthRatio: params.sourceHeadWidthRatio
159  };
160
161  let writeResult = null;
162  let writeError = null;
163  try {
164    const response = await fetch('/api/save-id-photo-silhouette', {
165      method: 'POST',
166      headers: { 'Content-Type': 'application/json' },
167      body: JSON.stringify(payload)
168    });
169    writeResult = await response.json();
170    if (!response.ok || !writeResult.ok) {
171      throw new Error(writeResult?.error || `Save failed (${response.status})`);
172    }
173  } catch (error) {
174    writeError = error;
175  }
176
177  const snippet = [
178    `silhouetteScale: ${params.scale},`,
179    `silhouetteHeightScale: ${params.heightScale},`,
180    `silhouetteOffsetXRatio: ${params.offsetXRatio},`,
181    `silhouetteOffsetYRatio: ${params.offsetYRatio},`,
182    `silhouetteSourceHeadWidthRatio: ${params.sourceHeadWidthRatio},`
183  ].join('\n');
184
185  try {
186    await navigator.clipboard.writeText(snippet);
187  } catch (_) {
188    // Clipboard may be blocked; UI still shows values.
189  }
190
191  calibrateSnapshot = null;
192  updateCropOption('idPhotoSilhouetteCalibrate', false);
193  setIdPhotoGuideOverride(presetKey, {
194    silhouetteScale: params.scale,
195    silhouetteHeightScale: params.heightScale,
196    silhouetteOffsetXRatio: params.offsetXRatio,
197    silhouetteOffsetYRatio: params.offsetYRatio,
198    silhouetteSourceHeadWidthRatio: params.sourceHeadWidthRatio
199  });
200  applySilhouetteParams(params, { render: true });
201  syncCalibratePanel({
202    status: writeError
203      ? `已复制参数,但写入文件失败:${writeError.message}。请确认用 npm run dev 启动。`
204      : `已写入 ${presetKey},并复制到剪贴板。`,
205    statusType: writeError ? 'error' : 'success'
206  });
207
208  return { ok: !writeError, params, writeResult, writeError, snippet };
209}
210
211export function syncCalibrateReadout() {
212  const params = formatSilhouetteParams();
213  const scaleEl = document.getElementById('id-photo-silhouette-scale');
214  const heightScaleEl = document.getElementById('id-photo-silhouette-height-scale');
215  const offsetXEl = document.getElementById('id-photo-silhouette-offset-x');
216  const offsetYEl = document.getElementById('id-photo-silhouette-offset-y');
217  const scaleValueEl = document.getElementById('id-photo-silhouette-scale-value');
218  const heightScaleValueEl = document.getElementById('id-photo-silhouette-height-scale-value');
219  const offsetXValueEl = document.getElementById('id-photo-silhouette-offset-x-value');
220  const offsetYValueEl = document.getElementById('id-photo-silhouette-offset-y-value');
221
222  if (scaleEl) scaleEl.value = String(params.scale);
223  if (heightScaleEl) heightScaleEl.value = String(params.heightScale);
224  if (offsetXEl) offsetXEl.value = String(params.offsetXRatio);
225  if (offsetYEl) offsetYEl.value = String(params.offsetYRatio);
226  if (scaleValueEl) scaleValueEl.textContent = params.scale.toFixed(3);
227  if (heightScaleValueEl) heightScaleValueEl.textContent = params.heightScale.toFixed(3);
228  if (offsetXValueEl) offsetXValueEl.textContent = params.offsetXRatio.toFixed(3);
229  if (offsetYValueEl) offsetYValueEl.textContent = params.offsetYRatio.toFixed(3);
230}
231
232export function syncCalibratePanel(extra = {}) {
233  const panel = document.getElementById('id-photo-silhouette-calibrate-panel');
234  const toggle = document.getElementById('id-photo-silhouette-calibrate-checkbox');
235  const controls = document.getElementById('id-photo-silhouette-calibrate-control
235s');
236  const statusEl = document.getElementById('id-photo-silhouette-calibrate-status');
237  if (!panel) return;
238
239  const enabled = isSilhouetteCalibrateMode();
240  if (toggle) toggle.checked = enabled;
241  if (controls) controls.hidden = !enabled;
242  panel.classList.toggle('is-active', enabled);
243  syncCalibrateReadout();
244
245  if (statusEl && Object.prototype.hasOwnProperty.call(extra, 'status')) {
246    statusEl.textContent = extra.status || '';
247    statusEl.dataset.type = extra.statusType || '';
248  }
249}
250
251export function getSilhouetteCalibratePanelHTML() {
252  const enabled = isSilhouetteCalibrateMode();
253  const params = formatSilhouetteParams();
254  return `
255    <div class="id-photo-calibrate ${enabled ? 'is-active' : ''}" id="id-photo-silhouette-calibrate-panel">
256      <label class="id-photo-helper-toggle">
257        <input type="checkbox" id="id-photo-silhouette-calibrate-checkbox" ${enabled ? 'checked' : ''}>
258        <span>校准黑色人像轮廓</span>
259      </label>
260      <p class="id-photo-calibrate-hint">开启后会锁定裁剪框,可拖动/缩放轮廓;调好后点「确定并写入」保存到当前 preset。</p>
261      <div class="id-photo-calibrate-controls" id="id-photo-silhouette-calibrate-controls" ${enabled ? '' : 'hidden'}>
262        <label class="id-photo-calibrate-field">
263          <span>Scale <strong id="id-photo-silhouette-scale-value">${params.scale.toFixed(3)}</strong></span>
264          <input type="range" id="id-photo-silhouette-scale" min="0.4" max="1.6" step="0.01" value="${params.scale}">
265        </label>
266        <label class="id-photo-calibrate-field">
267          <span>Height Scale <strong id="id-photo-silhouette-height-scale-value">${params.heightScale.toFixed(3)}</strong></span>
268          <input type="range" id="id-photo-silhouette-height-scale" min="0.7" max="1.6" step="0.01" value="${params.heightScale}">
269        </label>
270        <label class="id-photo-calibrate-field">
271          <span>Offset X <strong id="id-photo-silhouette-offset-x-value">${params.offsetXRatio.toFixed(3)}</strong></span>
272          <input type="range" id="id-photo-silhouette-offset-x" min="-0.25" max="0.25" step="0.005" value="${params.offsetXRatio}">
273        </label>
274        <label class="id-photo-calibrate-field">
275          <span>Offset Y <strong id="id-photo-silhouette-offset-y-value">${params.offsetYRatio.toFixed(3)}</strong></span>
276          <input type="range" id="id-photo-silhouette-offset-y" min="-0.25" max="0.25" step="0.005" value="${params.offsetYRatio}">
277        </label>
278        <div class="id-photo-calibrate-actions">
279          <button type="button" class="btn btn-secondary" id="id-photo-silhouette-calibrate-cancel">取消</button>
280          <button type="button" class="btn btn-primary" id="id-photo-silhouette-calibrate-confirm">确定并写入</button>
281        </div>
282        <p class="id-photo-calibrate-status" id="id-photo-silhouette-calibrate-status" data-type=""></p>
283      </div>
284    </div>
285  `;
286}
287
288export function bindSilhouetteCalibratePanel(root = document) {
289  const toggle = root.querySelector('#id-photo-silhouette-calibrate-checkbox');
290  if (toggle && !toggle.dataset.bound) {
291    toggle.dataset.bound = '1';
292    toggle.addEventListener('change', () => {
293      if (toggle.checked) enableSilhouetteCalibrate();
294      else cancelSilhouetteCalibrate();
295    });
296  }
297
298  const scaleEl = root.querySelector('#id-photo-silhouette-scale');
299  const heightScaleEl = root.querySelector('#id-photo-silhouette-height-scale');
300  const offsetXEl = root.querySelector('#id-photo-silhouette-offset-x');
301  const offsetYEl = root.querySelector('#id-photo-silhouette-offset-y');
302  const cancelBtn = root.querySelector('#id-photo-silhouette-calibrate-cancel');
303  const confirmBtn = root.querySelector('#id-photo-silhouette-calibrate-confirm');
304
305  const bindRange = (el, key) => {
306    if (!el || el.dataset.bound) return;
307    el.dataset.bound = '1';
308    el.addEventListener('input', () => {
309      applySilhouetteParams({ [key]: Number(el.value) });
310    });
311  };
312
313  bindRange(scaleEl, 'scale');
314  bindRange(heightScaleEl, 'heightScale');
315  bindRange(offsetXEl, 'offsetXRatio');
316  bindRange(offsetYEl, 'offsetYRatio');
317
318  if (cancelBtn && !cancelBtn.dataset.bound) {
319    cancelBtn.dataset.bound = '1';
320    cancelBtn.addEventListener('click', () => cancelSilhouetteCalibrate());
321  }
322
323  if (confirmBtn && !confirmBtn.dataset.bound) {
324    confirmBtn.dataset.bound = '1';
325    confirmBtn.addEventListener('click', async () => {
326      confirmBtn.disabled = true;
327      try {
328        await confirmSilhouetteCalibrate();
329      } finally {
330        confirmBtn.disabled = false;
331      }
332    });
333  }
334
335  syncCalibratePanel();
336}
337
338function parsePercent(value) {
339  const n = parseFloat(value);
340  return Number.isFinite(n) ? n / 100 : 0;
341}
342
343function paramsFromLayout(layout, meta) {
344  const width = Math.max(0.05, layout.width);
345  const scale = (width * meta.sourceHeadWidthRatio) / Math.max(meta.headWidth, 0.0001);
346  const heightScale = layout.height / Math.max(width * meta.cropAspect, 0.0001);
347  const offsetXRatio = layout.left - (meta.headCenterX - width / 2);
348  const offsetYRatio = layout.top - meta.headTop;
349  return {
350    scale: roundParam(scale),
351    heightScale: roundParam(Math.max(0.5, heightScale)),
352    offsetXRatio: roundParam(offsetXRatio),
353    offsetYRatio: roundParam(offsetYRatio),
354    sourceHeadWidthRatio: meta.sourceHeadWidthRatio
355  };
356}
357
358function layoutFromParams(params, meta) {
359  const heightScale = Number.isFinite(params.heightScale) && params.heightScale > 0 ? params.heightScale : 1;
360  const width = Math.max(0.05, (meta.headWidth / meta.sourceHeadWidthRatio) * params.scale);
361  const height = width * meta.cropAspect * heightScale;
362  const left = meta.headCenterX - width / 2 + params.offsetXRatio;
363  const top = meta.headTop + params.offsetYRatio;
364  return { left, top, width, height };
365}
366
367function applySilhouetteDom(silhouetteEl, layout) {
368  silhouetteEl.style.left = `${layout.left * 100}%`;
369  silhouetteEl.style.top = `${layout.top * 100}%`;
370  silhouetteEl.style.width = `${layout.width * 100}%`;
371  silhouetteEl.style.height = `${layout.height * 100}%`;
372}
373
374function readSilhouetteMeta(silhouetteEl) {
375  return {
376    headWidth: Number(silhouetteEl.dataset.headWidth) || 0.5,
377    headTop: Number(silhouetteEl.dataset.headTop) || 0,
378    headCenterX: Number(silhouetteEl.dataset.headCenterX) || 0.5,
379    cropAspect: Number(silhouetteEl.dataset.cropAspect) || 1,
380    sourceHeadWidthRatio: Number(silhouetteEl.dataset.sourceHeadWidthRatio) || DEFAULT_SOURCE_HEAD_WIDTH_RATIO,
381    heightScale: Number(silhouetteEl.dataset.heightScale) || 1
382  };
383}
384
385function handleSilhouetteDrag(e) {
386  if (!draggingSilhouette || !dragMeta || !dragStartParams) return;
387  const card = document.querySelector(`[data-id="${draggingSilhouette.imageId}"]`);
388  if (!card) return;
389  const cropBox = card.querySelector('.crop-box');
390  const silhouetteEl = card.querySelector('.idphoto-guide-silhouette');
391  if (!cropBox || !silhouetteEl) return;
392
393  const rect = cropBox.getBoundingClientRect();
394  if (rect.width <= 0 || rect.height <= 0) return;
395
396  const dx = (e.clientX - dragStart.x) / rect.width;
397  const dy = (e.clientY - dragStart.y) / rect.height;
398  let nextParams = { ...dragStartParams };
399  const startLayout = layoutFromParams(dragStartParams, dragMeta);
400  const type = draggingSilhouette.type;
401
402  if (type === 'move') {
403    nextParams.offsetXRatio = dragStartParams.offsetXRatio + dx;
404    nextParams.offsetYRatio = dragStartParams.offsetYRatio + dy;
405  } else if (type === 'n' || type === 's') {
406    // Vertical-only stretch: keep width, change heightScale; anchor the opposite edge.
407    const startBottom = startLayout.top + startLayout.height;
408    let nextTop = startLayout.top;
409    let nextHeight = startLayout.height;
410    if (type === 's') {
411      nextHeight = Math.max(0.08, startLayout.height + dy);
412    } else {
413      nextTop = startLayout.top + dy;
414      nextHeight = Math.max(0.08, startBottom - nextTop);
415    }
416    nextParams.heightScale = nextHeight / Math.max(startLayout.width * dragMeta.cropAspect, 0.0001);
417    nextParams.offsetYRatio = nextTop - dragMeta.headTop;
418  } else {
419    // Corner resize: keep heightScale, change overall scale around center.
420    const heightScale = Number.isFinite(dragStartParams.heightScale) && dragStartParams.heightScale > 0
421      ? dragStartParams.heightScale
422      : 1;
423    const centerX = startLayout.left + startLayout.width / 2;
424    const centerY = startLayout.top + startLayout.height / 2;
425    const pointerX = ((e.clientX - rect.left) / rect.width);
426    const pointerY = ((e.clientY - rect.top) / rect.height);
427    const halfW = Math.max(Math.abs(pointerX - centerX), 0.03);
428    const halfH = Math.max(Math.abs(pointerY - centerY), 0.03);
429    const widthFromX = halfW * 2;
430    const widthFromY = (halfH * 2) / Math.max(dragMeta.cropAspect * heightScale, 0.0001);
431    const width = Math.max(0.08, (widthFromX + widthFromY) / 2);
432    nextParams.scale = (width * dragMeta.sourceHeadWidthRatio) / Math.max(dragMeta.headWidth, 0.0001);
433    nextParams.heightScale = heightScale;
434  }
435
436  const layout = layoutFromParams(nextParams, dragMeta);
437  applySilhouetteDom(silhouetteEl, layout);
438  applySilhouetteParams(nextParams, { render: false });
439}
440
441function handleSilhouetteEnd() {
442  if (!draggingSilhouette) return;
443  draggingSilhouette = null;
444  dragStartParams = null;
445  dragMeta = null;
446  activePointerId = null;
447  import('./grid.js').then((grid) => grid.renderGrid());
448}
449
450export function setupSilhouetteCalibrateHandlers() {
451  if (!isSilhouetteCalibrateMode()) return;
452
453  const grid = document.getElementById('image-grid');
454  if (!grid) return;
455
456  grid.querySelectorAll('.idphoto-guide-silhouette.is-calibrating').forEach((silhouetteEl) => {
457    const imageId = silhouetteEl.dataset.imageId;
458    if (!imageId) return;
459
460    const startDrag = (type, clientX, clientY, pointerId = null, targetEl = silhouetteEl) => {
461      draggingSilhouette = { type, imageId };
462      dragStart = { x: clientX, y: clientY };
463      dragMeta = readSilhouetteMeta(silhouetteEl);
464      dragStartParams = paramsFromLayout({
465        left: parsePercent(silhouetteEl.style.left),
466        top: parsePercent(silhouetteEl.style.top),
467        width: parsePercent(silhouetteEl.style.width),
468        height: parsePercent(silhouetteEl.style.height)
469      }, dragMeta);
470      activePointerId = pointerId;
471      try {
472        if (pointerId !== null) targetEl.setPointerCapture(pointerId);
473      } catch (_) {}
474    };
475
476    silhouetteEl.addEventListener('pointerdown', (e) => {
477      if (e.target.classList.contains('silhouette-handle')) return;
478      if (activePointerId !== null) return;
479      startDrag('move', e.clientX, e.clientY, e.pointerId, silhouetteEl);
480      e.preventDefault();
481      e.stopPropagation();
482    }, { passive: false });
483
484    silhouetteEl.querySelectorAll('.silhouette-handle').forEach((handle) => {
485      handle.addEventListener('pointerdown', (e) => {
486        if (activePointerId !== null) return;
487        const handleType = Array.from(handle.classList).find((c) => ['nw', 'ne', 'sw', 'se', 'n', 's'].includes(c)) || 'se';
488        startDrag(handleType, e.clientX, e.clientY, e.pointerId, handle);
489        e.preventDefault();
490        e.stopPropagation();
491      }, { passive: false });
492    });
493  });
494
495  if (!handlersAttached) {
496    handlersAttached = true;
497    document.addEventListener('pointermove', (e) => {
498      if (!draggingSilhouette) return;
499      if (activePointerId !== null && e.pointerId !== activePointerId) return;
500      handleSilhouetteDrag(e);
501      e.preventDefault();
502    }, { passive: false });
503    document.addEventListener('pointerup', (e) => {
504      if (!draggingSilhouette) return;
505      if (activePointerId !== null && e.pointerId !== activePointerId) return;
506      handleSilhouetteEnd();
507    });
508    document.addEventListener('pointercancel', () => {
509      if (!draggingSilhouette) return;
510      handleSilhouetteEnd();
511    });
512  }
513}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.