From 3d21e59ccca3ec9f3ebc97acce5369ba20e5645a Mon Sep 17 00:00:00 2001
From: Rodney Osodo
Date: Mon, 14 Sep 2026 20:51:44 +0300
Subject: [PATCH 1/9] Add photo-first layouts with click-to-pick hole positions
Signed-off-by: Rodney Osodo
---
README.md | 48 +++++-
public/editor.css | 8 +
public/index.html | 9 ++
src/Builder.js | 64 +++++---
src/Editor.js | 87 ++++++++++-
src/ImageEmbed.js | 13 ++
src/ImageSize.js | 91 +++++++++++
src/PhotoLayout.js | 299 +++++++++++++++++++++++++++++++++++++
src/components/PhotoPcb.js | 40 +++++
src/web.js | 6 +-
10 files changed, 639 insertions(+), 26 deletions(-)
create mode 100644 src/ImageSize.js
create mode 100644 src/PhotoLayout.js
create mode 100644 src/components/PhotoPcb.js
diff --git a/README.md b/README.md
index 05e1148..736fe62 100644
--- a/README.md
+++ b/README.md
@@ -32,15 +32,18 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/
* **Front & Back Views:**
* Automatically generates diagrams for both the front and back sides of the PCB.
+* **Photo First Layouts:**
+ * Start with a photo of your PCB and place pin headers on it by clicking their first and last hole.
+ * The pin pitch is used to derive the real-world scale, no fiddling with image offsets.
+
## Screenshot

## Limits
- * Only four rows of pins are supported (left, right, top, bottom).
- * No weird arbitrary layouts.
- * Only the standard 0.1-inch pin raster is supported (might change in the future).
+ * Grid layouts: only four rows of pins are supported (left, right, top, bottom) on the standard 0.1-inch raster.
+ * Photo first layouts: pins within a header are equally spaced along a straight line. Labels always point left, right, up or down.
## Alternatives
@@ -69,6 +72,45 @@ Check the [pinouts](https://github.com/splitbrain/pinoutleaf/tree/gh-pages/pinou
Open a [pull request](https://github.com/splitbrain/pinoutleaf/pulls) or [ticket](https://github.com/splitbrain/pinoutleaf/issues) if you made one and want it included in the repo.
+### Photo First Layouts
+
+Instead of describing a pin grid and nudging the image until it matches, you can start with a photo and place the pins on it. Use `headers` instead of `width`, `height`, `offsets` and `pins`:
+
+```yaml
+title: "SIM7080G Module"
+
+image:
+ front:
+ src: "SIM7080G-front-pcb.png"
+
+headers:
+ - start: [ 672, 1682 ] # pixel position of the first hole on the image
+ end: [ 657, 2550 ] # pixel position of the last hole
+ pins:
+ - [ "DTR:uart" ]
+ - [ "PWR" ]
+ - [ "RTS:uart" ]
+ - [ ] # a hole without labels
+ - [ ]
+ - [ ]
+ - [ "3V3:power" ]
+ - [ "GND:gnd" ]
+```
+
+1. Set the front image and add a header with its `start` and `end`.
+2. In the web editor, check **📍 Pick coordinates**, put the editor cursor where the coordinates should go and click the hole on the photo. The pixel coordinates are inserted for you.
+3. Add as many entries to `pins` as the header has holes. The holes are spaced equally between `start` and `end`.
+4. Label the pins just like in grid layouts.
+
+Things to know:
+
+* The distance between the holes and the pin pitch determine the scale of the photo. Until a header with at least two pins exists, the diagram is marked as *not to scale*.
+* `pitch` sets the pin spacing in mm, either globally or per header. It defaults to `2.54`.
+* `count` sets the number of holes when you don't want to list every pin yet.
+* Use `null` (an empty list item) instead of `[ ]` to skip a hole.
+* `side` (`left`, `right`, `top`, `bottom`) sets where labels are drawn. It is guessed from the header's position when omitted and mirrored for the back view.
+* A back photo is centered at the front image's scale. Without a back image, the front photo is mirrored for the back view.
+
### Printing
* Download the SVG and open it in an SVG-capable tool — your browser is fine.
diff --git a/public/editor.css b/public/editor.css
index 53c3f8e..c9773a0 100644
--- a/public/editor.css
+++ b/public/editor.css
@@ -83,6 +83,14 @@ a {
border: 1px solid #ccc;
cursor: pointer;
}
+
+ #output.picking svg {
+ cursor: crosshair;
+ }
+
+ #output .error {
+ color: #cc322d;
+ }
}
/**
diff --git a/public/index.html b/public/index.html
index 16158b4..856d700 100644
--- a/public/index.html
+++ b/public/index.html
@@ -37,6 +37,15 @@
☝️ Click the images to download.
🖨️ Click here to print them.
+
+
+
+
+
diff --git a/src/Builder.js b/src/Builder.js
index 95a4813..7517f2f 100644
--- a/src/Builder.js
+++ b/src/Builder.js
@@ -8,6 +8,15 @@ import {Title} from "./components/Title.js";
import {Pcb} from "./components/Pcb.js";
import merge from 'lodash.merge';
import {RootGroup} from "./components/RootGroup.js";
+import {PhotoPcb} from "./components/PhotoPcb.js";
+import {PhotoLayout} from "./PhotoLayout.js";
+
+const SIDE_ALIGNMENT = {
+ left: 'leftof',
+ right: 'rightof',
+ top: 'above',
+ bottom: 'under',
+};
export class Builder {
@@ -93,6 +102,7 @@ export class Builder {
},
// pin label:type, each pin can have multiple labels
+ // Alternatively use `headers` to place pins on the front image by pixel coordinates (see PhotoLayout)
pins: {
left: [],
right: [],
@@ -106,7 +116,8 @@ export class Builder {
*/
constructor(setup) {
this.setup = merge(this.setup, setup);
- this.normalizePinArrays();
+ this.photo = this.setup.headers ? new PhotoLayout(this.setup) : null;
+ if (!this.photo) this.normalizePinArrays();
this.isFlipped = false;
}
@@ -122,24 +133,37 @@ export class Builder {
svg.append(root);
- // Create pin rows
const pinLayoutGroup = new Group();
- pinLayoutGroup.append(this.createPinRow('left', 'leftof'));
- pinLayoutGroup.append(this.createPinRow('right', 'rightof'));
- pinLayoutGroup.append(this.createPinRow('top', 'above'));
- pinLayoutGroup.append(this.createPinRow('bottom', 'under'));
- root.append(pinLayoutGroup);
+ if (this.photo) {
+ // Create holes placed on the photo
+ this.photo.holes(this.isFlipped).forEach(hole => {
+ pinLayoutGroup.append(this.createPinWithLabels(hole, hole.labels, SIDE_ALIGNMENT[hole.side]));
+ });
- // Add the PCB background
- const pcb = new Pcb(this.setup.width, this.setup.height, this.setup.image);
- pinLayoutGroup.prepend(pcb);
+ const {placement, bounds} = this.photo.pcb(this.isFlipped);
+ pinLayoutGroup.prepend(new PhotoPcb(placement, bounds));
+ } else {
+ // Create pin rows
+ pinLayoutGroup.append(this.createPinRow('left', 'leftof'));
+ pinLayoutGroup.append(this.createPinRow('right', 'rightof'));
+ pinLayoutGroup.append(this.createPinRow('top', 'above'));
+ pinLayoutGroup.append(this.createPinRow('bottom', 'under'));
+
+ // Add the PCB background
+ const pcb = new Pcb(this.setup.width, this.setup.height, this.setup.image);
+ pinLayoutGroup.prepend(pcb);
+ }
+ root.append(pinLayoutGroup);
// Create the title
- const title = new Title(this.setup.title + (this.isFlipped ? ' (back)' : ' (front)'));
+ let titleText = this.setup.title + (this.isFlipped ? ' (back)' : ' (front)');
+ if (this.photo && !this.photo.calibrated) titleText += ' - not to scale';
+ const title = new Title(titleText);
root.append(title)
// Create the legend
- const legend = new Legend(this.setup.types, this.setup.pins, this.isFlipped);
+ const pinsData = this.photo ? this.photo.pinsData() : this.setup.pins;
+ const legend = new Legend(this.setup.types, pinsData, this.isFlipped);
root.append(legend);
// Get bounding box of the main pin layout
@@ -149,7 +173,9 @@ export class Builder {
// Position legend to the bottom aligned to the right/left of the pin layout with padding
let rootBBox = root.getBoundingBox();
- if(this.isFlipped) {
+ if (!legendBBox) {
+ // no labels, no legend
+ } else if(this.isFlipped) {
// FIXME
// it's hacky:
// rect returns the visual bounding box that includes the stroke width
@@ -184,6 +210,7 @@ export class Builder {
*/
flip() {
this.isFlipped = !this.isFlipped;
+ if (this.photo) return; // photo layouts mirror their coordinates when building
// Swap left and right pins
const tempLeftPins = this.setup.pins.left;
@@ -251,19 +278,18 @@ export class Builder {
/**
* Creates a pin with its labels
- * @param {string} row The row identifier ('left', 'right', 'top', 'bottom')
- * @param {number} pinIndex The index of the pin in the row
+ * @param {{x: number, y: number}} pos The position of the pin
+ * @param {string[]} labels The labels for this pin in label:type format
* @param {string} alignment The alignment of labels ('leftof', 'rightof', 'above', 'under')
* @returns {Group} A group containing the pin and its labels
*/
- createPinWithLabels(row, pinIndex, alignment) {
+ createPinWithLabels(pos, labels, alignment) {
const group = new Group();
- const pos = this.pinPosition(row, pinIndex);
const pinElement = new Circle(pos.x, pos.y, PINSIZE, 'gold');
group.append(pinElement);
let last = pinElement;
- this.setup.pins[row][pinIndex].forEach((pindata, index) => {
+ labels.forEach((pindata, index) => {
const [text, type] = pindata.split(':');
const {bgcolor, fgcolor} = this.setup.types[type] || this.setup.types.default;
@@ -292,7 +318,7 @@ export class Builder {
for (let pin = 0; pin < pinCount; pin++) {
if (!this.setup.pins[row][pin]) continue; // pin is null
if (!this.setup.pins[row][pin].length) continue; // No definition for this pin, skip it
- const pinGroup = this.createPinWithLabels(row, pin, alignment);
+ const pinGroup = this.createPinWithLabels(this.pinPosition(row, pin), this.setup.pins[row][pin], alignment);
rowGroup.append(pinGroup);
}
diff --git a/src/Editor.js b/src/Editor.js
index 77f8e8a..efe3d1d 100644
--- a/src/Editor.js
+++ b/src/Editor.js
@@ -17,14 +17,23 @@ export class Editor {
ace;
/** @type {HTMLElement} The HTML element where the SVG output is rendered. */
output;
+ /** @type {HTMLInputElement|null} Checkbox enabling the coordinate picker. */
+ pickToggle;
+ /** @type {HTMLElement|null} Element showing the coordinates under the mouse. */
+ pickStatus;
/**
* Creates an Editor instance.
* @param {string|HTMLElement} editor - The ID or HTML element for the Ace editor container.
* @param {HTMLElement} output - The HTML element to render the SVG output into.
+ * @param {object} [controls] - Optional UI controls.
+ * @param {HTMLInputElement|null} [controls.pickToggle] - Checkbox to switch clicks from downloading to picking image coordinates.
+ * @param {HTMLElement|null} [controls.pickStatus] - Element to show the image coordinates under the mouse in.
*/
- constructor(editor, output) {
+ constructor(editor, output, {pickToggle = null, pickStatus = null} = {}) {
this.output = output;
+ this.pickToggle = pickToggle;
+ this.pickStatus = pickStatus;
this.ace = ace.edit(editor);
this.ace.setTheme("ace/theme/github");
@@ -42,7 +51,12 @@ export class Editor {
});
this.initializeEditor();
- this.output.addEventListener('click', this.onDownloadClick);
+ this.output.addEventListener('click', this.onOutputClick.bind(this));
+ this.output.addEventListener('mousemove', this.onOutputMove.bind(this));
+ this.pickToggle?.addEventListener('change', () => {
+ this.output.classList.toggle('picking', this.pickToggle.checked);
+ if (this.pickStatus) this.pickStatus.textContent = '';
+ });
}
/**
@@ -156,7 +170,17 @@ export class Editor {
const embed = new ImageEmbed('pinouts'); // Assuming 'pinouts' is the base path for local images
setup = await embed.embedImages(setup);
- const builder = new Builder(setup);
+ let builder;
+ try {
+ builder = new Builder(setup);
+ } catch (e) {
+ this.output.innerHTML = '';
+ const error = document.createElement('div');
+ error.className = 'error';
+ error.textContent = e.message;
+ this.output.appendChild(error);
+ return;
+ }
const front = builder.build().render(window.document);
builder.flip();
@@ -167,6 +191,63 @@ export class Editor {
this.output.appendChild(back);
}
+ /**
+ * Whether clicks on the output pick image coordinates instead of downloading
+ * @returns {boolean}
+ * @private
+ */
+ isPicking() {
+ return !!this.pickToggle?.checked;
+ }
+
+ /**
+ * Converts the mouse position into pixel coordinates of the PCB photo below it.
+ *
+ * Only works for layouts using headers, where the photo is rendered in its pixel coordinates.
+ *
+ * @param {MouseEvent} e
+ * @returns {{x: number, y: number, side: string}|null}
+ * @private
+ */
+ photoCoordinates(e) {
+ const photo = e.target.closest('svg')?.querySelector('.pcb-photo');
+ const matrix = photo?.getScreenCTM();
+ if (!matrix) return null;
+
+ const point = new DOMPoint(e.clientX, e.clientY).matrixTransform(matrix.inverse());
+ return {
+ x: Math.round(point.x),
+ y: Math.round(point.y),
+ side: photo.getAttribute('data-side'),
+ };
+ }
+
+ /**
+ * Shows the photo coordinates under the mouse while picking
+ * @param {MouseEvent} e
+ * @private
+ */
+ onOutputMove(e) {
+ if (!this.isPicking() || !this.pickStatus) return;
+ const coords = this.photoCoordinates(e);
+ this.pickStatus.textContent = coords ? `${coords.side} image: [${coords.x}, ${coords.y}]` : '';
+ }
+
+ /**
+ * Inserts the clicked photo coordinates at the editor cursor while picking, downloads the SVG otherwise
+ * @param {MouseEvent} e
+ * @private
+ */
+ onOutputClick(e) {
+ if (!this.isPicking()) {
+ this.onDownloadClick(e);
+ return;
+ }
+ const coords = this.photoCoordinates(e);
+ if (!coords) return;
+ this.ace.insert(`[ ${coords.x}, ${coords.y} ]`);
+ }
+
/**
* Handles click events on the output area to trigger SVG download.
* @param {MouseEvent} e - The click event object.
diff --git a/src/ImageEmbed.js b/src/ImageEmbed.js
index 9c14ffb..b23401c 100644
--- a/src/ImageEmbed.js
+++ b/src/ImageEmbed.js
@@ -1,3 +1,5 @@
+import {imageSize} from "./ImageSize.js";
+
// Basic environment detection
const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
@@ -67,6 +69,17 @@ export class ImageEmbed {
}
});
+ // Photo layouts place everything in image pixel coordinates and need to know the image dimensions
+ await Promise.all(imageConfigsToProcess.map(async ({config}) => {
+ try {
+ const {width, height} = await imageSize(config.src);
+ config.naturalWidth = width;
+ config.naturalHeight = height;
+ } catch (e) {
+ console.warn(e.message);
+ }
+ }));
+
return setup; // Return the modified setup object
}
diff --git a/src/ImageSize.js b/src/ImageSize.js
new file mode 100644
index 0000000..edf5f14
--- /dev/null
+++ b/src/ImageSize.js
@@ -0,0 +1,91 @@
+/**
+ * Reads the pixel dimensions of PNG and JPEG images without decoding them.
+ *
+ * Works in Node.js and the browser for data URIs and fetchable URLs.
+ */
+
+/** @type {Map} */
+const cache = new Map();
+
+/**
+ * @param {string} src A data URI or URL of a PNG or JPEG image
+ * @returns {Promise<{width: number, height: number}>}
+ * @throws {Error} If the image can't be loaded or its format is not supported
+ */
+export async function imageSize(src) {
+ if (cache.has(src)) return cache.get(src);
+
+ let size;
+ if (src.startsWith('data:')) {
+ const base64 = src.slice(src.indexOf(',') + 1);
+ // the header is usually at the very beginning, avoid decoding huge images completely
+ size = parseImageSize(decodeBase64(base64.slice(0, 65536))) ?? parseImageSize(decodeBase64(base64));
+ } else {
+ const response = await fetch(src);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch image '${src}': ${response.status} ${response.statusText}`);
+ }
+ size = parseImageSize(new Uint8Array(await response.arrayBuffer()));
+ }
+
+ if (!size) {
+ throw new Error(`Could not determine the dimensions of image '${src.substring(0, 100)}'. Only PNG and JPEG are supported.`);
+ }
+
+ cache.set(src, size);
+ return size;
+}
+
+/**
+ * @param {string} base64
+ * @returns {Uint8Array}
+ */
+function decodeBase64(base64) {
+ base64 = base64.slice(0, base64.length - (base64.length % 4));
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+
+/**
+ * @param {Uint8Array} bytes The (beginning of the) image file
+ * @returns {{width: number, height: number}|null} null if the format is unknown or the data is truncated
+ */
+export function parseImageSize(bytes) {
+ const u16 = (i) => (bytes[i] << 8) | bytes[i + 1];
+ const u32 = (i) => ((bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]) >>> 0;
+
+ // PNG: dimensions are the first thing in the IHDR chunk
+ if (bytes.length >= 24 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) {
+ return {width: u32(16), height: u32(20)};
+ }
+
+ // JPEG: walk the markers until we find a start-of-frame
+ if (bytes.length >= 4 && bytes[0] === 0xFF && bytes[1] === 0xD8) {
+ let i = 2;
+ while (i + 9 < bytes.length) {
+ if (bytes[i] !== 0xFF) {
+ i++;
+ continue;
+ }
+ const marker = bytes[i + 1];
+ if (marker === 0xFF) {
+ i++; // fill byte
+ continue;
+ }
+ if (marker === 0x01 || (marker >= 0xD0 && marker <= 0xD9)) {
+ i += 2; // markers without a payload
+ continue;
+ }
+ if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
+ return {width: u16(i + 7), height: u16(i + 5)};
+ }
+ i += 2 + u16(i + 2);
+ }
+ }
+
+ return null;
+}
diff --git a/src/PhotoLayout.js b/src/PhotoLayout.js
new file mode 100644
index 0000000..7dd6628
--- /dev/null
+++ b/src/PhotoLayout.js
@@ -0,0 +1,299 @@
+import {PINSPACE} from "./Constants.js";
+
+const UNCALIBRATED_SIZE = 5000; // 50mm, size of the longest image side when no scale can be derived
+const DEVIATION_WARNING = 0.05; // warn when a header's pin spacing is off by more than 5%
+const SIDES = ['left', 'right', 'top', 'bottom'];
+
+/**
+ * Image-first layout
+ *
+ * Instead of a fixed pin grid that the image has to be adjusted to, the PCB photo defines the
+ * coordinate system. Pin headers are placed on the photo by giving the pixel coordinates of their
+ * first and last hole, the holes in between are spaced equally. The known pin pitch is then used
+ * to derive the real world scale of the photo, so the output is still printable at 100%.
+ *
+ * All returned coordinates are in SVG units (1/100 mm).
+ */
+export class PhotoLayout {
+
+ /**
+ * @param {object} setup The full configuration, using `headers` instead of `pins`
+ */
+ constructor(setup) {
+ this.pitch = setup.pitch ?? 2.54;
+ this.front = setup.image?.front ?? {};
+ this.back = setup.image?.back ?? {};
+
+ if (!this.front.src) {
+ throw new Error('Layouts using headers need a front image (image.front.src)');
+ }
+ if (!this.front.naturalWidth || !this.front.naturalHeight) {
+ throw new Error(`Could not determine the dimensions of the front image "${this.front.src.substring(0, 100)}"`);
+ }
+ if (this.back.src && (!this.back.naturalWidth || !this.back.naturalHeight)) {
+ throw new Error(`Could not determine the dimensions of the back image "${this.back.src.substring(0, 100)}"`);
+ }
+
+ this.headers = (setup.headers ?? []).map((header, index) => this.normalizeHeader(header, index));
+ this.scale = this.calibrate();
+ this.sheetWidth = this.front.naturalWidth * this.scale; // the axis we mirror the back side on
+ this.backMatrix = this.fitBack();
+ }
+
+ /**
+ * Validate a header definition, fill in defaults and calculate its hole positions
+ *
+ * @param {object} header
+ * @param {int} index
+ * @returns {{side: string, pitch: number, pins: Array, holes: {x: number, y: number}[]}}
+ */
+ normalizeHeader(header, index) {
+ const name = `Header ${index + 1}`;
+
+ // pins can be given as a single label string for convenience
+ let pins = (header?.pins ?? []).map(pin => {
+ if (pin === null || pin === undefined) return null;
+ return (Array.isArray(pin) ? pin : [pin]).map(String);
+ });
+
+ const count = header?.count ?? pins.length;
+ if (pins.length > count) {
+ console.warn(`${name}: ${pins.length} pins defined, but count is ${count}. Ignoring excess pins.`);
+ pins = pins.slice(0, count);
+ }
+ pins = pins.concat(Array(count - pins.length).fill([]));
+
+ const holes = this.holePositions(header, count, name);
+ const side = header.side ?? this.guessSide(holes);
+ if (!SIDES.includes(side)) {
+ throw new Error(`${name}: side must be one of ${SIDES.join(', ')}`);
+ }
+
+ return {
+ side,
+ pitch: header.pitch ?? this.pitch,
+ pins,
+ holes,
+ };
+ }
+
+ /**
+ * Space holes equally between the start and end coordinates
+ *
+ * @param {object} coordinates Object with start and end pixel coordinates
+ * @param {int} count Number of holes
+ * @param {string} name Header name for error messages
+ * @returns {{x: number, y: number}[]}
+ */
+ holePositions(coordinates, count, name) {
+ const isPoint = (p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite);
+
+ if (!isPoint(coordinates?.start)) {
+ throw new Error(`${name}: start must be the pixel coordinates [x, y] of the first hole`);
+ }
+ if (count > 1 && !isPoint(coordinates.end)) {
+ throw new Error(`${name}: end must be the pixel coordinates [x, y] of the last hole`);
+ }
+
+ const [x1, y1] = coordinates.start;
+ const [x2, y2] = count > 1 ? coordinates.end : coordinates.start;
+ if (count > 1 && x1 === x2 && y1 === y2) {
+ throw new Error(`${name}: start and end can't be the same point`);
+ }
+
+ return Array.from({length: count}, (_, i) => {
+ const t = count > 1 ? i / (count - 1) : 0;
+ return {x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t};
+ });
+ }
+
+ /**
+ * Labels point away from the board: vertical headers get their labels on the closer
+ * of the left or right edge, horizontal ones on the closer of top or bottom.
+ *
+ * @param {{x: number, y: number}[]} holes
+ * @returns {string}
+ */
+ guessSide(holes) {
+ if (!holes.length) return 'left';
+ const first = holes[0];
+ const last = holes[holes.length - 1];
+ const cx = (first.x + last.x) / 2;
+ const cy = (first.y + last.y) / 2;
+ if (Math.abs(last.x - first.x) <= Math.abs(last.y - first.y)) {
+ return cx < this.front.naturalWidth / 2 ? 'left' : 'right';
+ }
+ return cy < this.front.naturalHeight / 2 ? 'top' : 'bottom';
+ }
+
+ /**
+ * Derive the scale (SVG units per front image pixel) from the hole spacing of all headers
+ *
+ * @returns {number}
+ */
+ calibrate() {
+ const estimates = [];
+ this.headers.forEach((header, index) => {
+ if (header.holes.length < 2) return;
+ const first = header.holes[0];
+ const last = header.holes[header.holes.length - 1];
+ const pixelPitch = Math.hypot(last.x - first.x, last.y - first.y) / (header.holes.length - 1);
+ estimates.push({index, scale: header.pitch * PINSPACE / 2.54 / pixelPitch});
+ });
+
+ if (!estimates.length) {
+ this.calibrated = false;
+ return UNCALIBRATED_SIZE / Math.max(this.front.naturalWidth, this.front.naturalHeight);
+ }
+ this.calibrated = true;
+
+ const scale = estimates.reduce((sum, e) => sum + e.scale, 0) / estimates.length;
+ estimates.forEach(({index, scale: estimate}) => {
+ const deviation = Math.abs(estimate - scale) / scale;
+ if (deviation > DEVIATION_WARNING) {
+ console.warn(
+ `Header ${index + 1}: hole spacing deviates ${Math.round(deviation * 100)}% from the average. ` +
+ `Check its start and end coordinates and its pin count.`
+ );
+ }
+ });
+ return scale;
+ }
+
+ /**
+ * Where a front hole ends up in the back view
+ *
+ * @param {{x: number, y: number}} hole Front image pixel coordinates
+ * @returns {{x: number, y: number}}
+ */
+ mirrorHole({x, y}) {
+ return {x: this.sheetWidth - x * this.scale, y: y * this.scale};
+ }
+
+ /**
+ * Find the transformation from back image pixels to back view coordinates
+ *
+ * The back photo is centered at the front image's scale. Without a back image, the front image is mirrored.
+ *
+ * @returns {number[]} SVG transformation matrix
+ */
+ fitBack() {
+ const s = this.scale;
+ if (!this.back.src) {
+ return [-s, 0, 0, s, this.sheetWidth, 0];
+ }
+
+ return [
+ s, 0, 0, s,
+ (this.front.naturalWidth - this.back.naturalWidth) * s / 2,
+ (this.front.naturalHeight - this.back.naturalHeight) * s / 2,
+ ];
+ }
+
+ /**
+ * All holes with their labels as seen from the given side
+ *
+ * @param {boolean} flipped Whether to look at the back side
+ * @returns {{x: number, y: number, side: string, labels: string[]}[]}
+ */
+ holes(flipped) {
+ const mirroredSide = {left: 'right', right: 'left'};
+
+ return this.headers.flatMap(header => header.holes
+ .map((hole, i) => {
+ if (header.pins[i] === null) return null; // explicitly no hole here
+
+ if (!flipped) {
+ return {x: hole.x * this.scale, y: hole.y * this.scale, side: header.side, labels: header.pins[i]};
+ }
+
+ return {
+ ...this.mirrorHole(hole),
+ side: mirroredSide[header.side] ?? header.side,
+ labels: header.pins[i],
+ };
+ })
+ .filter(Boolean)
+ );
+ }
+
+ /**
+ * Pin definitions in the format the legend expects
+ *
+ * @returns {Array>}
+ */
+ pinsData() {
+ return this.headers.map(header => header.pins);
+ }
+
+ /**
+ * The photo to show for the given side and the PCB area it occupies
+ *
+ * The area is the same for front and back (mirrored), so both diagrams can be folded on top of each other.
+ *
+ * @param {boolean} flipped Whether to look at the back side
+ * @returns {{placement: object, bounds: {x: number, y: number, width: number, height: number}}}
+ */
+ pcb(flipped) {
+ const front = this.placement(this.front, 'front', [this.scale, 0, 0, this.scale, 0, 0]);
+ const back = this.back.src
+ ? this.placement(this.back, 'back', this.backMatrix)
+ : this.placement(this.front, 'front', this.backMatrix);
+
+ const bounds = this.union(this.transformRect(front), this.mirrorRect(this.transformRect(back)));
+
+ return flipped
+ ? {placement: back, bounds: this.mirrorRect(bounds)}
+ : {placement: front, bounds};
+ }
+
+ /**
+ * @param {object} image The image configuration
+ * @param {string} side Which photo this is
+ * @param {number[]} matrix SVG transformation matrix from image pixels to SVG units
+ * @returns {object}
+ */
+ placement(image, side, matrix) {
+ return {
+ src: image.src,
+ side,
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ opacity: image.opacity ?? 0.5,
+ grayscale: image.grayscale ?? true,
+ matrix,
+ };
+ }
+
+ transformPoint([a, b, c, d, e, f], {x, y}) {
+ return {x: a * x + c * y + e, y: b * x + d * y + f};
+ }
+
+ /**
+ * Axis aligned bounding box of a transformed image
+ */
+ transformRect({width, height, matrix}) {
+ const corners = [{x: 0, y: 0}, {x: width, y: 0}, {x: 0, y: height}, {x: width, y: height}]
+ .map(corner => this.transformPoint(matrix, corner));
+ const xs = corners.map(p => p.x);
+ const ys = corners.map(p => p.y);
+ const x = Math.min(...xs);
+ const y = Math.min(...ys);
+ return {x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y};
+ }
+
+ mirrorRect(rect) {
+ return {...rect, x: this.sheetWidth - rect.x - rect.width};
+ }
+
+ union(r1, r2) {
+ const x = Math.min(r1.x, r2.x);
+ const y = Math.min(r1.y, r2.y);
+ return {
+ x,
+ y,
+ width: Math.max(r1.x + r1.width, r2.x + r2.width) - x,
+ height: Math.max(r1.y + r1.height, r2.y + r2.height) - y,
+ };
+ }
+}
diff --git a/src/components/PhotoPcb.js b/src/components/PhotoPcb.js
new file mode 100644
index 0000000..ec49ef3
--- /dev/null
+++ b/src/components/PhotoPcb.js
@@ -0,0 +1,40 @@
+import {Group} from '../elements/Group.js';
+import {Image} from '../elements/Image.js';
+
+/**
+ * The PCB photo of an image-first layout (see PhotoLayout)
+ *
+ * The image is drawn in its own pixel coordinates and transformed into place. This keeps the
+ * pixel coordinates recoverable from the rendered SVG, which the editor uses to pick hole positions.
+ */
+export class PhotoPcb extends Group {
+
+ /**
+ * @param {object} placement Image placement from PhotoLayout.pcb()
+ * @param {{x: number, y: number, width: number, height: number}} bounds The area this PCB occupies
+ */
+ constructor(placement, bounds) {
+ super();
+ this.bounds = bounds;
+
+ const {src, side, width, height, opacity, grayscale, matrix} = placement;
+ const attrs = {
+ transform: `matrix(${matrix.join(' ')})`,
+ opacity,
+ class: 'pcb-photo',
+ 'data-side': side,
+ };
+ if (grayscale) {
+ attrs.filter = 'url(#grayscale)';
+ }
+
+ this.append(new Image(0, 0, width, height, src, attrs));
+ }
+
+ /**
+ * The image's own bounding box ignores its transform, so we report the precalculated area
+ */
+ getBoundingBox() {
+ return {...this.bounds};
+ }
+}
diff --git a/src/web.js b/src/web.js
index b87edf9..0d2ee7d 100644
--- a/src/web.js
+++ b/src/web.js
@@ -6,7 +6,11 @@ import {Editor} from "./Editor.js";
const editor = new Editor(
document.getElementById('editor'),
- document.getElementById('output')
+ document.getElementById('output'),
+ {
+ pickToggle: document.getElementById('pick-toggle'),
+ pickStatus: document.getElementById('pick-status'),
+ }
);
From 88833daef7907b48ab2565f5e7663132cf05552d Mon Sep 17 00:00:00 2001
From: Rodney Osodo
Date: Mon, 14 Sep 2026 20:52:15 +0300
Subject: [PATCH 2/9] Let headers be placed on the back photo too
Signed-off-by: Rodney Osodo
---
README.md | 17 +++++---
public/index.html | 2 +-
src/PhotoLayout.js | 103 +++++++++++++++++++++++++++++++++++++++++----
3 files changed, 108 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index 736fe62..5f28e4d 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/
* **Photo First Layouts:**
* Start with a photo of your PCB and place pin headers on it by clicking their first and last hole.
* The pin pitch is used to derive the real-world scale, no fiddling with image offsets.
+ * Front and back photos can be placed independently.
## Screenshot
@@ -82,10 +83,15 @@ title: "SIM7080G Module"
image:
front:
src: "SIM7080G-front-pcb.png"
+ back:
+ src: "SIM7080G-back-pcb.png"
headers:
- - start: [ 672, 1682 ] # pixel position of the first hole on the image
+ - start: [ 672, 1682 ] # pixel position of the first hole on the front image
end: [ 657, 2550 ] # pixel position of the last hole
+ back: # optional: the same header on the back image
+ start: [ 1728, 1695 ]
+ end: [ 1728, 2518 ]
pins:
- [ "DTR:uart" ]
- [ "PWR" ]
@@ -97,8 +103,8 @@ headers:
- [ "GND:gnd" ]
```
-1. Set the front image and add a header with its `start` and `end`.
-2. In the web editor, check **📍 Pick coordinates**, put the editor cursor where the coordinates should go and click the hole on the photo. The pixel coordinates are inserted for you.
+1. Set the front (and back) image and add a header with its `start` and `end`.
+2. In the web editor, check **📍 Pick coordinates**, put the editor cursor where the coordinates should go and click the hole on the photo. The pixel coordinates are inserted for you. Clicking the back photo gives back image coordinates.
3. Add as many entries to `pins` as the header has holes. The holes are spaced equally between `start` and `end`.
4. Label the pins just like in grid layouts.
@@ -108,8 +114,9 @@ Things to know:
* `pitch` sets the pin spacing in mm, either globally or per header. It defaults to `2.54`.
* `count` sets the number of holes when you don't want to list every pin yet.
* Use `null` (an empty list item) instead of `[ ]` to skip a hole.
-* `side` (`left`, `right`, `top`, `bottom`) sets where labels are drawn. It is guessed from the header's position when omitted and mirrored for the back view.
-* A back photo is centered at the front image's scale. Without a back image, the front photo is mirrored for the back view.
+* `side` (`left`, `right`, `top`, `bottom`) sets where labels are drawn. It is guessed from the header's position when omitted and mirrored for the back view. Use `back.side` to override it for the back.
+* The back photo is scaled and rotated so its holes match the front ones. Without `back` coordinates, it is centered at the front image's scale. Without a back image, the front photo is mirrored.
+* `back` `start` and `end` must point to the same first and last pin as on the front. Check the console for warnings when things don't line up.
### Printing
diff --git a/public/index.html b/public/index.html
index 856d700..e6909df 100644
--- a/public/index.html
+++ b/public/index.html
@@ -42,7 +42,7 @@
diff --git a/src/PhotoLayout.js b/src/PhotoLayout.js
index 7dd6628..8fd367a 100644
--- a/src/PhotoLayout.js
+++ b/src/PhotoLayout.js
@@ -2,6 +2,7 @@ import {PINSPACE} from "./Constants.js";
const UNCALIBRATED_SIZE = 5000; // 50mm, size of the longest image side when no scale can be derived
const DEVIATION_WARNING = 0.05; // warn when a header's pin spacing is off by more than 5%
+const MISMATCH_WARNING = 100; // warn when back holes are more than 1mm away from their front counterparts
const SIDES = ['left', 'right', 'top', 'bottom'];
/**
@@ -12,6 +13,9 @@ const SIDES = ['left', 'right', 'top', 'bottom'];
* first and last hole, the holes in between are spaced equally. The known pin pitch is then used
* to derive the real world scale of the photo, so the output is still printable at 100%.
*
+ * Headers can optionally be placed on the back photo independently. The back photo is then fitted
+ * onto the (mirrored) front, so both diagrams can still be folded on top of each other.
+ *
* All returned coordinates are in SVG units (1/100 mm).
*/
export class PhotoLayout {
@@ -45,7 +49,7 @@ export class PhotoLayout {
*
* @param {object} header
* @param {int} index
- * @returns {{side: string, pitch: number, pins: Array, holes: {x: number, y: number}[]}}
+ * @returns {{side: string, pitch: number, pins: Array, holes: {x: number, y: number}[], back: {side: string|undefined, holes: {x: number, y: number}[]}|null}}
*/
normalizeHeader(header, index) {
const name = `Header ${index + 1}`;
@@ -69,11 +73,26 @@ export class PhotoLayout {
throw new Error(`${name}: side must be one of ${SIDES.join(', ')}`);
}
+ let back = null;
+ if (header.back) {
+ if (!this.back.src) {
+ throw new Error(`${name}: back coordinates need a back image (image.back.src)`);
+ }
+ back = {
+ side: header.back.side,
+ holes: this.holePositions(header.back, count, `${name} (back)`),
+ };
+ if (back.side !== undefined && !SIDES.includes(back.side)) {
+ throw new Error(`${name} (back): side must be one of ${SIDES.join(', ')}`);
+ }
+ }
+
return {
side,
pitch: header.pitch ?? this.pitch,
pins,
holes,
+ back,
};
}
@@ -173,7 +192,9 @@ export class PhotoLayout {
/**
* Find the transformation from back image pixels to back view coordinates
*
- * The back photo is centered at the front image's scale. Without a back image, the front image is mirrored.
+ * When headers have back coordinates, the back photo is scaled, rotated and moved so its holes
+ * match the mirrored front holes as closely as possible (least squares). Otherwise it is centered
+ * at the front image's scale. Without a back image, the front image is mirrored.
*
* @returns {number[]} SVG transformation matrix
*/
@@ -183,11 +204,74 @@ export class PhotoLayout {
return [-s, 0, 0, s, this.sheetWidth, 0];
}
- return [
- s, 0, 0, s,
- (this.front.naturalWidth - this.back.naturalWidth) * s / 2,
- (this.front.naturalHeight - this.back.naturalHeight) * s / 2,
+ const pairs = this.headers
+ .filter(header => header.back)
+ .flatMap(header => header.holes.map((hole, i) => ({
+ from: header.back.holes[i],
+ to: this.mirrorHole(hole),
+ })));
+
+ const distinct = new Set(pairs.map(({from}) => `${from.x},${from.y}`));
+ if (distinct.size < 2) {
+ if (pairs.length) {
+ console.warn('Back coordinates need at least two different holes to align the back image, ignoring them.');
+ }
+ return [
+ s, 0, 0, s,
+ (this.front.naturalWidth - this.back.naturalWidth) * s / 2,
+ (this.front.naturalHeight - this.back.naturalHeight) * s / 2,
+ ];
+ }
+
+ // similarity transform to = a * from + t, with a and t as complex numbers
+ const mean = (points) => ({
+ x: points.reduce((sum, p) => sum + p.x, 0) / points.length,
+ y: points.reduce((sum, p) => sum + p.y, 0) / points.length,
+ });
+ const fromMean = mean(pairs.map(p => p.from));
+ const toMean = mean(pairs.map(p => p.to));
+
+ let re = 0, im = 0, norm = 0;
+ pairs.forEach(({from, to}) => {
+ const fx = from.x - fromMean.x, fy = from.y - fromMean.y;
+ const tx = to.x - toMean.x, ty = to.y - toMean.y;
+ re += tx * fx + ty * fy;
+ im += ty * fx - tx * fy;
+ norm += fx * fx + fy * fy;
+ });
+ re /= norm;
+ im /= norm;
+
+ const matrix = [
+ re, im, -im, re,
+ toMean.x - (re * fromMean.x - im * fromMean.y),
+ toMean.y - (im * fromMean.x + re * fromMean.y),
];
+
+ const rotation = Math.atan2(im, re) * 180 / Math.PI;
+ if (Math.abs(rotation) > 45) {
+ console.warn(
+ `The back image had to be rotated by ${Math.round(rotation)}° to match the front. ` +
+ `Make sure back start and end point to the same first and last pin as on the front.`
+ );
+ }
+
+ this.headers.forEach((header, index) => {
+ if (!header.back) return;
+ const offset = Math.max(...header.holes.map((hole, i) => {
+ const fitted = this.transformPoint(matrix, header.back.holes[i]);
+ const target = this.mirrorHole(hole);
+ return Math.hypot(fitted.x - target.x, fitted.y - target.y);
+ }));
+ if (offset > MISMATCH_WARNING) {
+ console.warn(
+ `Header ${index + 1}: back holes are up to ${(offset / 100).toFixed(1)}mm off from the front. ` +
+ `Check the back start and end coordinates, they need to point to the same first and last pin as on the front.`
+ );
+ }
+ });
+
+ return matrix;
}
/**
@@ -207,9 +291,12 @@ export class PhotoLayout {
return {x: hole.x * this.scale, y: hole.y * this.scale, side: header.side, labels: header.pins[i]};
}
+ const position = header.back
+ ? this.transformPoint(this.backMatrix, header.back.holes[i])
+ : this.mirrorHole(hole);
return {
- ...this.mirrorHole(hole),
- side: mirroredSide[header.side] ?? header.side,
+ ...position,
+ side: header.back?.side ?? mirroredSide[header.side] ?? header.side,
labels: header.pins[i],
};
})
From 48b53af06e8722c63d54c95a86ce9465bb8f5445 Mon Sep 17 00:00:00 2001
From: Rodney Osodo
Date: Mon, 14 Sep 2026 20:52:43 +0300
Subject: [PATCH 3/9] Support WebP, GIF, AVIF, BMP and SVG images
Signed-off-by: Rodney Osodo
---
README.md | 2 +
src/Editor.js | 9 +-
src/ImageEmbed.js | 105 +++++++++++------
src/ImageInfo.js | 282 ++++++++++++++++++++++++++++++++++++++++++++++
src/ImageSize.js | 91 ---------------
5 files changed, 360 insertions(+), 129 deletions(-)
create mode 100644 src/ImageInfo.js
delete mode 100644 src/ImageSize.js
diff --git a/README.md b/README.md
index 5f28e4d..53caece 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/
* Generates clean, scalable SVG diagrams.
* Default sizes match the real-world PCB exactly — just print at 100%.
* Fully self-contained, font and image resources are embedded into the file so it's fully portable.
+ * Images can be PNG, JPEG, GIF, WebP, AVIF, BMP or SVG, from local files or URLs.
* **Configuration:**
* Define pinouts, board dimensions, labels, and types using simple YAML or JSON files.
@@ -110,6 +111,7 @@ headers:
Things to know:
+* Coordinates are in image pixels. For SVG images they are in the units of the SVG's `viewBox`.
* The distance between the holes and the pin pitch determine the scale of the photo. Until a header with at least two pins exists, the diagram is marked as *not to scale*.
* `pitch` sets the pin spacing in mm, either globally or per header. It defaults to `2.54`.
* `count` sets the number of holes when you don't want to list every pin yet.
diff --git a/src/Editor.js b/src/Editor.js
index efe3d1d..a595d0e 100644
--- a/src/Editor.js
+++ b/src/Editor.js
@@ -204,6 +204,8 @@ export class Editor {
* Converts the mouse position into pixel coordinates of the PCB photo below it.
*
* Only works for layouts using headers, where the photo is rendered in its pixel coordinates.
+ * Coordinates are rounded to about one screen pixel, so images with small units (like
+ * SVGs using millimeters) still get precise coordinates.
*
* @param {MouseEvent} e
* @returns {{x: number, y: number, side: string}|null}
@@ -215,9 +217,12 @@ export class Editor {
if (!matrix) return null;
const point = new DOMPoint(e.clientX, e.clientY).matrixTransform(matrix.inverse());
+ const unitsPerScreenPixel = 1 / Math.hypot(matrix.a, matrix.b);
+ const decimals = Math.min(3, Math.max(0, Math.ceil(-Math.log10(unitsPerScreenPixel))));
+ const round = (value) => Number(value.toFixed(decimals));
return {
- x: Math.round(point.x),
- y: Math.round(point.y),
+ x: round(point.x),
+ y: round(point.y),
side: photo.getAttribute('data-side'),
};
}
diff --git a/src/ImageEmbed.js b/src/ImageEmbed.js
index b23401c..45743bc 100644
--- a/src/ImageEmbed.js
+++ b/src/ImageEmbed.js
@@ -1,4 +1,4 @@
-import {imageSize} from "./ImageSize.js";
+import {imageSize, sniffImageType, SUPPORTED_TYPES, toDataUri} from "./ImageInfo.js";
// Basic environment detection
const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
@@ -6,8 +6,8 @@ const isNode = typeof process !== 'undefined' && process.versions != null && pro
/**
* Handles embedding images as data URIs, supporting both Node.js and browser environments.
* In Node.js, it fetches remote URLs or reads local files directly.
- * In the browser, it utilizes a remote service to fetch and convert images.
- * Only supports JPEG and PNG image types.
+ * In the browser, it fetches images directly and falls back to a remote service for servers without CORS headers.
+ * Supports PNG, JPEG, GIF, WebP, AVIF, BMP and SVG images (the remote service only handles PNG and JPEG).
*/
export class ImageEmbed {
/**
@@ -95,6 +95,9 @@ export class ImageEmbed {
if (!source) {
throw new Error("Image source cannot be empty.");
}
+ if (source.startsWith('data:')) {
+ return source; // already embedded
+ }
if (isNode) {
return this.embedNode(source);
@@ -105,9 +108,8 @@ export class ImageEmbed {
/**
* Embeds an image in a Node.js environment.
- * Fetches remote URLs or reads local files directly, determines the MIME type,
- * and converts the image data to a base64 data URI.
- * Supports only JPEG and PNG images.
+ * Fetches remote URLs or reads local files directly, detects the image type
+ * from its contents and converts the image data to a base64 data URI.
*
* @param {string} source - The URL (http/https) or local file path of the image.
* @returns {Promise} A promise that resolves with the image data URI.
@@ -119,47 +121,27 @@ export class ImageEmbed {
const fs = await import('fs/promises');
const path = await import('path');
- let buffer;
- let mimeType;
-
+ let bytes;
if (source.startsWith('http://') || source.startsWith('https://')) {
- const response = await fetch(source);
- if (!response.ok) {
- throw new Error(`Failed to fetch image '${source}': ${response.status} ${response.statusText}`);
- }
- buffer = Buffer.from(await response.arrayBuffer());
- mimeType = response.headers.get('content-type')?.split(';')[0].toLowerCase();
+ bytes = await this.fetchBytes(source);
} else {
source = this.base ? path.join(this.base, source) : source;
-
- buffer = await fs.readFile(source);
- const extension = path.extname(source).toLowerCase();
- if (extension === '.jpg' || extension === '.jpeg') {
- mimeType = 'image/jpeg';
- } else if (extension === '.png') {
- mimeType = 'image/png';
- } else {
- mimeType = null; // Will be caught by validation below
- }
- }
-
- if (mimeType !== 'image/jpeg' && mimeType !== 'image/png') {
- throw new Error(`Unsupported image type for source "${source}". Only JPEG and PNG are supported. Detected type: ${mimeType || 'unknown'}`);
+ bytes = new Uint8Array(await fs.readFile(source));
}
- const base64 = buffer.toString('base64');
- const dataUri = `data:${mimeType};base64,${base64}`;
+ const dataUri = this.toImageDataUri(bytes, source);
console.debug(`Generated data URI for ${source} (length: ${dataUri.length})`);
return dataUri;
}
/**
- * Embeds an image in a browser environment by calling a remote service.
- * The service is expected to handle fetching/conversion and return the data URI.
+ * Embeds an image in a browser environment.
+ * The image is fetched directly. If that fails (usually because the server does not send
+ * CORS headers), a remote service is used to fetch and convert the image instead.
*
- * @param {string} source - The URL (http/https) or potentially a path resolvable by the service.
+ * @param {string} source - The URL (http/https) or a path relative to the current page.
* @returns {Promise} A promise that resolves with the image data URI.
- * @throws {Error} If the service call fails or returns an invalid response.
+ * @throws {Error} If the image can't be fetched, is unsupported or the service call fails.
* @private
*/
async embedBrowser(source) {
@@ -173,10 +155,33 @@ export class ImageEmbed {
if (source.match(/^http:\/\/(localhost|127.0.0.1)/)) return source; // no local embeds
}
+ let bytes;
+ try {
+ bytes = await this.fetchBytes(source);
+ } catch (e) {
+ console.debug(`Direct fetch of '${source}' failed, using embed service: ${e.message}`);
+ return this.embedService(source);
+ }
+
+ const dataUri = this.toImageDataUri(bytes, source);
+ console.debug(`Generated data URI for ${source} (length: ${dataUri.length})`);
+ return dataUri;
+ }
+
+ /**
+ * Embeds an image by calling a remote service that fetches it server side.
+ * The service only supports PNG and JPEG images.
+ *
+ * @param {string} source - The URL of the image.
+ * @returns {Promise} A promise that resolves with the image data URI.
+ * @throws {Error} If the service call fails or returns an invalid response.
+ * @private
+ */
+ async embedService(source) {
const embedServiceUrl = `${ImageEmbed.SERVICE}?url=${encodeURIComponent(source)}`;
const response = await fetch(embedServiceUrl);
if (!response.ok) {
- throw new Error(`Failed to fetch data URI from service for '${source}': ${response.status} ${response.statusText}`);
+ throw new Error(`Failed to fetch data URI from service for '${source}': ${response.status} ${response.statusText} ${await response.text()}`);
}
const dataUri = await response.text();
@@ -188,4 +193,32 @@ export class ImageEmbed {
return dataUri;
}
+ /**
+ * @param {string} url
+ * @returns {Promise}
+ * @private
+ */
+ async fetchBytes(url) {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch image '${url}': ${response.status} ${response.statusText}`);
+ }
+ return new Uint8Array(await response.arrayBuffer());
+ }
+
+ /**
+ * @param {Uint8Array} bytes The image file contents
+ * @param {string} source The image source for error messages
+ * @returns {string} The data URI
+ * @throws {Error} If the image type is unsupported
+ * @private
+ */
+ toImageDataUri(bytes, source) {
+ const mimeType = sniffImageType(bytes);
+ if (!mimeType) {
+ throw new Error(`Unsupported image type for source "${source}". Supported types: ${SUPPORTED_TYPES.join(', ')}`);
+ }
+ return toDataUri(bytes, mimeType);
+ }
+
}
diff --git a/src/ImageInfo.js b/src/ImageInfo.js
new file mode 100644
index 0000000..e74da7f
--- /dev/null
+++ b/src/ImageInfo.js
@@ -0,0 +1,282 @@
+/**
+ * Detects image types and reads image dimensions without decoding the images.
+ *
+ * Works in Node.js and the browser. Supports PNG, JPEG, GIF, WebP, AVIF, BMP and SVG.
+ */
+
+/** Mime types of all supported image formats */
+export const SUPPORTED_TYPES = [
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ 'image/webp',
+ 'image/avif',
+ 'image/bmp',
+ 'image/svg+xml',
+];
+
+/** @type {Map} */
+const cache = new Map();
+
+/**
+ * Get the dimensions of an image
+ *
+ * For SVGs this is the size of the viewBox, or its width and height in pixels if there is no viewBox.
+ *
+ * @param {string} src A data URI or URL of an image
+ * @returns {Promise<{width: number, height: number}>}
+ * @throws {Error} If the image can't be loaded or its format is not supported
+ */
+export async function imageSize(src) {
+ if (cache.has(src)) return cache.get(src);
+
+ let size;
+ if (src.startsWith('data:')) {
+ const {bytes, partial} = dataUriBytes(src, 65536);
+ // the header is usually at the very beginning, avoid decoding huge images completely
+ size = parseImageSize(bytes) ?? (partial ? parseImageSize(dataUriBytes(src).bytes) : null);
+ } else {
+ try {
+ const response = await fetch(src);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch image '${src}': ${response.status} ${response.statusText}`);
+ }
+ size = parseImageSize(new Uint8Array(await response.arrayBuffer()));
+ } catch (e) {
+ // in the browser, images from servers without CORS headers can still be displayed and measured
+ if (typeof globalThis.Image !== 'function') throw e;
+ size = await browserImageSize(src);
+ }
+ }
+
+ if (!size) {
+ throw new Error(`Could not determine the dimensions of image '${src.substring(0, 100)}'. Supported types: ${SUPPORTED_TYPES.join(', ')}`);
+ }
+
+ cache.set(src, size);
+ return size;
+}
+
+/**
+ * Detect the image type from the file contents
+ *
+ * @param {Uint8Array} bytes The (beginning of the) image file
+ * @returns {string|null} The mime type or null if it's not a supported image
+ */
+export function sniffImageType(bytes) {
+ const ascii = (start, length) => String.fromCharCode(...bytes.subarray(start, start + length));
+
+ if (bytes[0] === 0x89 && ascii(1, 3) === 'PNG') return 'image/png';
+ if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg';
+ if (ascii(0, 4) === 'GIF8') return 'image/gif';
+ if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') return 'image/webp';
+ if (ascii(0, 2) === 'BM' && bytes.length > 26) return 'image/bmp';
+ if (ascii(4, 4) === 'ftyp') {
+ const boxSize = Math.min(u32be(bytes, 0), bytes.length);
+ for (let i = 8; i + 4 <= boxSize; i += 4) {
+ if (['avif', 'avis'].includes(ascii(i, 4))) return 'image/avif';
+ }
+ return null;
+ }
+
+ const text = new TextDecoder().decode(bytes.subarray(0, 4096)).replace(/^\uFEFF/, '').trimStart();
+ if (text.startsWith('<') && /