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 ![Screenshot](screenshot.png) ## 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('<') && /]| 0 && size.height > 0 ? size : null; + } catch (e) { + return null; // truncated data + } +} + +/** + * Convert image data into a base64 data URI + * + * @param {Uint8Array} bytes + * @param {string} mimeType + * @returns {string} + */ +export function toDataUri(bytes, mimeType) { + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return `data:${mimeType};base64,${btoa(binary)}`; +} + +/** + * @param {Uint8Array} bytes + * @returns {{width: number, height: number}|null} + */ +function readSize(bytes) { + const u16le = (i) => bytes[i] | (bytes[i + 1] << 8); + const u24le = (i) => bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16); + const u32le = (i) => u24le(i) + bytes[i + 3] * 0x1000000; + const i32le = (i) => u32le(i) | 0; + const ascii = (start, length) => String.fromCharCode(...bytes.subarray(start, start + length)); + const check = (end) => { + if (end > bytes.length) throw new Error('truncated'); + }; + + switch (sniffImageType(bytes)) { + case 'image/png': + check(24); + return {width: u32be(bytes, 16), height: u32be(bytes, 20)}; + + case 'image/gif': + check(10); + return {width: u16le(6), height: u16le(8)}; + + case 'image/bmp': + if (u32le(14) === 12) return {width: u16le(18), height: u16le(20)}; // OS/2 header + return {width: i32le(18), height: Math.abs(i32le(22))}; // negative height means top-down + + case 'image/webp': + check(30); + switch (ascii(12, 4)) { + case 'VP8 ': // lossy + return {width: u16le(26) & 0x3FFF, height: u16le(28) & 0x3FFF}; + case 'VP8L': { // lossless + const bits = u32le(21); + return {width: (bits & 0x3FFF) + 1, height: ((bits >> 14) & 0x3FFF) + 1}; + } + case 'VP8X': // extended + return {width: u24le(24) + 1, height: u24le(27) + 1}; + } + return null; + + case 'image/avif': + // the image spatial extents property holds the dimensions + for (let i = 4; i + 16 <= bytes.length; i++) { + if (bytes[i] === 0x69 && ascii(i, 4) === 'ispe') { + return {width: u32be(bytes, i + 8), height: u32be(bytes, i + 12)}; + } + } + throw new Error('truncated'); + + case 'image/jpeg': { + 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: u16be(bytes, i + 7), height: u16be(bytes, i + 5)}; + } + i += 2 + u16be(bytes, i + 2); + } + throw new Error('truncated'); + } + + case 'image/svg+xml': + return svgSize(new TextDecoder().decode(bytes)); + } + + return null; +} + +/** + * @param {string} svg The SVG source + * @returns {{width: number, height: number}|null} + */ +function svgSize(svg) { + const tag = svg.match(/]*>/i)?.[0]; + if (!tag) throw new Error('truncated'); + + const attribute = (name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(["'])(.*?)\\1`, 'i'))?.[2]; + + const viewBox = attribute('viewBox')?.trim().split(/[\s,]+/).map(Number); + if (viewBox?.length === 4 && viewBox[2] > 0 && viewBox[3] > 0) { + return {width: viewBox[2], height: viewBox[3]}; + } + + const width = cssPixels(attribute('width')); + const height = cssPixels(attribute('height')); + return width && height ? {width, height} : null; +} + +/** + * @param {string|undefined} length An absolute SVG length like "25.4mm" + * @returns {number|null} The length in CSS pixels, null for relative or invalid lengths + */ +function cssPixels(length) { + const units = {'': 1, px: 1, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, pt: 96 / 72, pc: 16}; + const match = length?.trim().match(/^([\d.]+(?:e[+-]?\d+)?)\s*([a-z]*)$/i); + if (!match || !(match[2].toLowerCase() in units)) return null; + return parseFloat(match[1]) * units[match[2].toLowerCase()]; +} + +/** + * Decode the contents of a data URI + * + * @param {string} src The data URI + * @param {number} [limit] Only decode about this many characters + * @returns {{bytes: Uint8Array, partial: boolean}} + */ +function dataUriBytes(src, limit = Infinity) { + const comma = src.indexOf(','); + const isBase64 = /;base64$/i.test(src.slice(0, comma)); + let payload = src.slice(comma + 1); + const partial = payload.length > limit; + + if (!isBase64) { + try { + payload = decodeURIComponent(payload); + } catch (e) { + // not percent encoded + } + return {bytes: new TextEncoder().encode(payload), partial: false}; + } + + if (partial) payload = payload.slice(0, limit - (limit % 4)); + const binary = atob(payload); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return {bytes, partial}; +} + +/** + * Measure an image by letting the browser load it + * + * @param {string} src + * @returns {Promise<{width: number, height: number}|null>} + */ +function browserImageSize(src) { + return new Promise((resolve, reject) => { + const img = new globalThis.Image(); + img.onload = () => resolve(img.naturalWidth ? {width: img.naturalWidth, height: img.naturalHeight} : null); + img.onerror = () => reject(new Error(`Failed to load image '${src}'`)); + img.src = src; + }); +} + +function u16be(bytes, i) { + if (i + 2 > bytes.length) throw new Error('truncated'); + return (bytes[i] << 8) | bytes[i + 1]; +} + +function u32be(bytes, i) { + if (i + 4 > bytes.length) throw new Error('truncated'); + return ((bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]) >>> 0; +} diff --git a/src/ImageSize.js b/src/ImageSize.js deleted file mode 100644 index edf5f14..0000000 --- a/src/ImageSize.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * 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; -} From f752ff29debef89f59e3540097ab5b45114ba832 Mon Sep 17 00:00:00 2001 From: Rodney Osodo Date: Mon, 14 Sep 2026 20:53:06 +0300 Subject: [PATCH 4/9] Add a grayscale toggle to the editor Signed-off-by: Rodney Osodo --- README.md | 1 + public/index.html | 7 +++++ src/Editor.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++- src/YamlEdit.js | 60 +++++++++++++++++++++++++++++++++++++++++ src/web.js | 1 + 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/YamlEdit.js diff --git a/README.md b/README.md index 53caece..5b743e8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/ * **Web Editor:** * Real-time preview of the diagram as you edit the configuration. * Syntax highlighting for YAML. + * Switch PCB images between grayscale and color (sets `grayscale` of the images in the YAML). * Download generated SVG diagrams. * **CLI Tool:** diff --git a/public/index.html b/public/index.html index e6909df..d5a4fa9 100644 --- a/public/index.html +++ b/public/index.html @@ -38,6 +38,13 @@ 🖨️ Click here to print them.

+

+ +

+

-

+

+

diff --git a/src/Editor.js b/src/Editor.js index e366735..0e1e97c 100644 --- a/src/Editor.js +++ b/src/Editor.js @@ -24,6 +24,10 @@ export class Editor { pickStatus; /** @type {HTMLInputElement|null} Checkbox switching the PCB images between grayscale and color. */ grayscaleToggle; + /** @type {HTMLInputElement|null} Range input for the opacity of the PCB images in percent. */ + opacitySlider; + /** @type {HTMLElement|null} Element showing the current opacity. */ + opacityValue; /** * Creates an Editor instance. @@ -33,12 +37,22 @@ export class Editor { * @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. * @param {HTMLInputElement|null} [controls.grayscaleToggle] - Checkbox to switch the images between grayscale and color. + * @param {HTMLInputElement|null} [controls.opacitySlider] - Range input (0-100) for the image opacity. + * @param {HTMLElement|null} [controls.opacityValue] - Element to show the current opacity in. */ - constructor(editor, output, {pickToggle = null, pickStatus = null, grayscaleToggle = null} = {}) { + constructor(editor, output, { + pickToggle = null, + pickStatus = null, + grayscaleToggle = null, + opacitySlider = null, + opacityValue = null, + } = {}) { this.output = output; this.pickToggle = pickToggle; this.pickStatus = pickStatus; this.grayscaleToggle = grayscaleToggle; + this.opacitySlider = opacitySlider; + this.opacityValue = opacityValue; this.ace = ace.edit(editor); this.ace.setTheme("ace/theme/github"); @@ -63,6 +77,8 @@ export class Editor { if (this.pickStatus) this.pickStatus.textContent = ''; }); this.grayscaleToggle?.addEventListener('change', this.onGrayscaleToggle.bind(this)); + this.opacitySlider?.addEventListener('input', this.onOpacityInput.bind(this)); + this.opacitySlider?.addEventListener('change', this.onOpacityChange.bind(this)); } /** @@ -201,8 +217,8 @@ export class Editor { /** * Reflects the image settings of the configuration in the image controls * - * The grayscale toggle is checked when all images are grayscale (the default), indeterminate - * when front and back differ and disabled when there are no images. + * The grayscale toggle is checked when all images are grayscale (the default) and indeterminate + * when front and back differ. All controls are disabled when there are no images. * * @param {object} setup - The parsed YAML configuration object. * @private @@ -218,6 +234,21 @@ export class Editor { this.grayscaleToggle.checked = grayscale.length > 0 && grayscale.every(Boolean); this.grayscaleToggle.indeterminate = grayscale.some(Boolean) && !grayscale.every(Boolean); } + + if (this.opacitySlider) { + const opacities = [...new Set(images.map(image => Math.round(Number(image.opacity ?? 0.5) * 100)))]; + this.opacitySlider.disabled = images.length === 0; + this.opacitySlider.value = opacities.length ? opacities[0] : 50; + this.showOpacity(opacities.length ? opacities.map(o => `${o}%`).join(' / ') : ''); + } + } + + /** + * @param {string} text + * @private + */ + showOpacity(text) { + if (this.opacityValue) this.opacityValue.textContent = text; } /** @@ -230,6 +261,24 @@ export class Editor { } } + /** + * Previews the opacity while the slider is dragged, without touching the YAML + * @private + */ + onOpacityInput() { + const opacity = this.opacitySlider.value / 100; + this.output.querySelectorAll('svg image').forEach(image => image.setAttribute('opacity', opacity)); + this.showOpacity(`${this.opacitySlider.value}%`); + } + + /** + * Sets the opacity option of all images in the YAML once the slider is released + * @private + */ + onOpacityChange() { + this.setImageOption('opacity', this.opacitySlider.value / 100); + } + /** * Sets an option of all images in the YAML * diff --git a/src/YamlEdit.js b/src/YamlEdit.js index b8be21f..0264d30 100644 --- a/src/YamlEdit.js +++ b/src/YamlEdit.js @@ -1,7 +1,7 @@ import Yaml from 'yaml'; /** - * Calculates minimal text edits to set an option (like grayscale) of all images in a YAML configuration + * Calculates minimal text edits to set an option (like grayscale or opacity) of all images in a YAML configuration * * Rewriting the whole document would reformat the user's YAML, so only the option's values are * replaced, or a line for the option is inserted after the image's src. diff --git a/src/web.js b/src/web.js index b4efdd6..dbc1eaa 100644 --- a/src/web.js +++ b/src/web.js @@ -11,6 +11,8 @@ import {Editor} from "./Editor.js"; pickToggle: document.getElementById('pick-toggle'), pickStatus: document.getElementById('pick-status'), grayscaleToggle: document.getElementById('grayscale-toggle'), + opacitySlider: document.getElementById('opacity-slider'), + opacityValue: document.getElementById('opacity-value'), } ); From dc0b4f1d74704088a6ad48dfb8827fb3ae778d7d Mon Sep 17 00:00:00 2001 From: Rodney Osodo Date: Mon, 14 Sep 2026 20:53:55 +0300 Subject: [PATCH 6/9] Don't let undo wipe the loaded definition Signed-off-by: Rodney Osodo --- src/Editor.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Editor.js b/src/Editor.js index 0e1e97c..0dc359c 100644 --- a/src/Editor.js +++ b/src/Editor.js @@ -88,6 +88,7 @@ export class Editor { */ async initializeEditor() { await this.loadInitialContent(); + this.ace.session.getUndoManager().reset(); // undo should not remove the loaded content this.ace.session.on('change', this.debounce(this.onChange.bind(this), 300)); if (this.ace.getValue()) { From ce428586132d12de4616457d047566c2dac5ffa8 Mon Sep 17 00:00:00 2001 From: Rodney Osodo Date: Mon, 14 Sep 2026 21:05:43 +0300 Subject: [PATCH 7/9] Handle remote image URLs more gracefully Signed-off-by: Rodney Osodo --- README.md | 3 ++- public/editor.css | 10 ++++++++- src/Editor.js | 24 ++++++++++++++++++---- src/ImageEmbed.js | 51 ++++++++++++++++++++++++++++++++++------------ src/ImageInfo.js | 36 ++++++++++++++++++++++++++------ src/PhotoLayout.js | 23 +++++++++++++++------ 6 files changed, 116 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index fe321fa..3047422 100644 --- a/README.md +++ b/README.md @@ -16,7 +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. + * Images can be PNG, JPEG, GIF, WebP, AVIF, BMP or SVG, from local files or `https://` URLs. * **Configuration:** * Define pinouts, board dimensions, labels, and types using simple YAML or JSON files. @@ -27,6 +27,7 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/ * Syntax highlighting for YAML. * Switch PCB images between grayscale and color and adjust their opacity with a slider (sets `grayscale` and `opacity` of the images in the YAML). * Download generated SVG diagrams. + * Remote images are embedded into the download. Servers that block this are linked instead, the editor shows a notice when that happens. * **CLI Tool:** * Process multiple configuration files or entire directories to generate SVG diagrams programmatically. diff --git a/public/editor.css b/public/editor.css index fc39722..ae84467 100644 --- a/public/editor.css +++ b/public/editor.css @@ -108,13 +108,21 @@ a { #output .error { color: #cc322d; } + + #output .notice { + margin-bottom: 1em; + padding: 0.5em 1em; + border: 1px solid #e38022; + background: #fdf3e8; + overflow-wrap: anywhere; + } } /** * Print Styles */ @media print { - p { + p, #output .notice { display: none; } diff --git a/src/Editor.js b/src/Editor.js index 0dc359c..7aa8e01 100644 --- a/src/Editor.js +++ b/src/Editor.js @@ -199,10 +199,7 @@ export class Editor { 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); + this.output.appendChild(this.message('error', e.message)); return; } @@ -211,10 +208,29 @@ export class Editor { const back = builder.build().render(window.document); this.output.innerHTML = ''; + embed.problems.forEach(({side, message, linked}) => { + this.output.appendChild(this.message('notice', linked + ? `⚠️ The ${side} image could not be embedded, it is linked instead. The downloaded SVG needs internet access to show it. (${message})` + : `⚠️ The ${side} image could not be loaded: ${message}` + )); + }); this.output.appendChild(front); this.output.appendChild(back); } + /** + * @param {string} className + * @param {string} text + * @returns {HTMLDivElement} + * @private + */ + message(className, text) { + const div = document.createElement('div'); + div.className = className; + div.textContent = text; + return div; + } + /** * Reflects the image settings of the configuration in the image controls * diff --git a/src/ImageEmbed.js b/src/ImageEmbed.js index 45743bc..6415c8b 100644 --- a/src/ImageEmbed.js +++ b/src/ImageEmbed.js @@ -1,4 +1,4 @@ -import {imageSize, sniffImageType, SUPPORTED_TYPES, toDataUri} from "./ImageInfo.js"; +import {FETCH_TIMEOUT, fetchImage, imageSize, sniffImageType, SUPPORTED_TYPES, toDataUri} from "./ImageInfo.js"; // Basic environment detection const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; @@ -22,6 +22,15 @@ export class ImageEmbed { */ constructor(base = '') { this.base = base; + + /** + * Problems with the images of the last embedImages() call + * + * `linked` is true when the image could not be embedded, but can still be shown from its original URL. + * + * @type {{side: string, src: string, message: string, linked: boolean}[]} + */ + this.problems = []; } /** @@ -32,17 +41,21 @@ export class ImageEmbed { * with data URIs upon successful embedding. If embedding fails for an image, * a warning is logged, and the original `src` value is retained. * + * The image dimensions are stored as `naturalWidth` and `naturalHeight`. Images that can't be + * loaded at all get an `error` message instead. See `problems` for a summary. + * * @param {object} setup - The setup configuration object. Expected to potentially have `setup.image.front.src` and `setup.image.back.src`. * @returns {Promise} A promise that resolves with the (potentially modified) setup object. */ async embedImages(setup) { + this.problems = []; const imageConfigsToProcess = []; // Collect image sources to process, storing references to the config objects if (setup?.image?.front?.src) { - imageConfigsToProcess.push({config: setup.image.front, originalSrc: setup.image.front.src}); + imageConfigsToProcess.push({side: 'front', config: setup.image.front, originalSrc: setup.image.front.src}); } if (setup?.image?.back?.src) { - imageConfigsToProcess.push({config: setup.image.back, originalSrc: setup.image.back.src}); + imageConfigsToProcess.push({side: 'back', config: setup.image.back, originalSrc: setup.image.back.src}); } if (imageConfigsToProcess.length === 0) { @@ -65,18 +78,32 @@ export class ImageEmbed { item.config.src = result.value; } else { // Embed failed: Log a warning, leave the original src untouched - console.warn(`Failed to embed image "${item.originalSrc}": ${result.reason?.message || result.reason}. Keeping original source.`); + item.error = result.reason?.message || String(result.reason); + console.warn(`Failed to embed image "${item.originalSrc}": ${item.error}. Keeping original source.`); } }); // Photo layouts place everything in image pixel coordinates and need to know the image dimensions - await Promise.all(imageConfigsToProcess.map(async ({config}) => { + await Promise.all(imageConfigsToProcess.map(async (item) => { + const {side, config, originalSrc} = item; + const problem = (message, linked) => this.problems.push({side, src: originalSrc, message, linked}); + + // in Node.js there is no other way to load the image, don't try again + if (item.error && isNode) { + config.error = item.error; + problem(item.error, false); + return; + } + try { const {width, height} = await imageSize(config.src); config.naturalWidth = width; config.naturalHeight = height; + if (item.error) problem(item.error, true); // shown from its URL, but not embedded } catch (e) { console.warn(e.message); + config.error = item.error ?? e.message; + problem(config.error, false); } })); @@ -159,6 +186,7 @@ export class ImageEmbed { try { bytes = await this.fetchBytes(source); } catch (e) { + if (e.status) throw e; // the server answered, the service won't get a better response console.debug(`Direct fetch of '${source}' failed, using embed service: ${e.message}`); return this.embedService(source); } @@ -170,7 +198,7 @@ export class ImageEmbed { /** * Embeds an image by calling a remote service that fetches it server side. - * The service only supports PNG and JPEG images. + * This works for servers that don't send CORS headers, but only for PNG and JPEG images. * * @param {string} source - The URL of the image. * @returns {Promise} A promise that resolves with the image data URI. @@ -179,9 +207,10 @@ export class ImageEmbed { */ async embedService(source) { const embedServiceUrl = `${ImageEmbed.SERVICE}?url=${encodeURIComponent(source)}`; - const response = await fetch(embedServiceUrl); + const response = await fetch(embedServiceUrl, {signal: AbortSignal.timeout(FETCH_TIMEOUT)}); if (!response.ok) { - throw new Error(`Failed to fetch data URI from service for '${source}': ${response.status} ${response.statusText} ${await response.text()}`); + const reason = (await response.text()).trim() || `HTTP ${response.status}`; + throw new Error(`Could not fetch '${source}' directly (the server is unreachable or does not allow it) and the embed service failed: ${reason}`); } const dataUri = await response.text(); @@ -199,11 +228,7 @@ export class ImageEmbed { * @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()); + return fetchImage(url); } /** diff --git a/src/ImageInfo.js b/src/ImageInfo.js index e74da7f..6b1b463 100644 --- a/src/ImageInfo.js +++ b/src/ImageInfo.js @@ -15,9 +15,37 @@ export const SUPPORTED_TYPES = [ 'image/svg+xml', ]; +/** Maximum time to wait for a remote image */ +export const FETCH_TIMEOUT = 30000; + /** @type {Map} */ const cache = new Map(); +/** + * Fetch an image from a URL + * + * @param {string} url + * @returns {Promise} + * @throws {Error} On network errors and timeouts, or with a `status` property when the server responds with an HTTP error + */ +export async function fetchImage(url) { + let response; + try { + response = await fetch(url, {signal: AbortSignal.timeout(FETCH_TIMEOUT)}); + } catch (e) { + if (e.name === 'TimeoutError') { + throw new Error(`Timed out after ${FETCH_TIMEOUT / 1000}s fetching image '${url}'`); + } + throw new Error(`Failed to fetch image '${url}': ${e.cause?.message ?? e.message}`); + } + if (!response.ok) { + const error = new Error(`Failed to fetch image '${url}': HTTP ${response.status} ${response.statusText}`.trim()); + error.status = response.status; + throw error; + } + return new Uint8Array(await response.arrayBuffer()); +} + /** * Get the dimensions of an image * @@ -37,14 +65,10 @@ export async function imageSize(src) { 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())); + size = parseImageSize(await fetchImage(src)); } catch (e) { // in the browser, images from servers without CORS headers can still be displayed and measured - if (typeof globalThis.Image !== 'function') throw e; + if (typeof globalThis.Image !== 'function' || e.status) throw e; size = await browserImageSize(src); } } diff --git a/src/PhotoLayout.js b/src/PhotoLayout.js index 8fd367a..52a920f 100644 --- a/src/PhotoLayout.js +++ b/src/PhotoLayout.js @@ -31,12 +31,8 @@ export class PhotoLayout { 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.checkImage(this.front, 'front'); + if (this.back.src) this.checkImage(this.back, 'back'); this.headers = (setup.headers ?? []).map((header, index) => this.normalizeHeader(header, index)); this.scale = this.calibrate(); @@ -44,6 +40,21 @@ export class PhotoLayout { this.backMatrix = this.fitBack(); } + /** + * Make sure the dimensions of an image are known + * + * @param {object} image The image configuration + * @param {string} side Which image this is + * @throws {Error} If the image could not be loaded or measured + */ + checkImage(image, side) { + if (image.naturalWidth && image.naturalHeight) return; + if (image.error) { + throw new Error(`Could not load the ${side} image: ${image.error}`); + } + throw new Error(`Could not determine the dimensions of the ${side} image "${image.src.substring(0, 100)}"`); + } + /** * Validate a header definition, fill in defaults and calculate its hole positions * From dc8c6eb7e891745175a24dc5cb6fbeaf2a7b6f45 Mon Sep 17 00:00:00 2001 From: Rodney Osodo Date: Tue, 15 Sep 2026 10:40:23 +0300 Subject: [PATCH 8/9] Add SVG and PNG download options Signed-off-by: Rodney Osodo --- README.md | 2 +- public/editor.css | 2 +- public/index.html | 24 ++++- src/Download.js | 266 ++++++++++++++++++++++++++++++++++++++++++++++ src/Editor.js | 82 +++++++++++--- src/web.js | 4 + 6 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 src/Download.js diff --git a/README.md b/README.md index 3047422..d5af25a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/ * Real-time preview of the diagram as you edit the configuration. * Syntax highlighting for YAML. * Switch PCB images between grayscale and color and adjust their opacity with a slider (sets `grayscale` and `opacity` of the images in the YAML). - * Download generated SVG diagrams. + * Download the front, the back or both (as two files or combined into one) as SVG or PNG. PNGs have 300 DPI and print in real size, too. * Remote images are embedded into the download. Servers that block this are linked instead, the editor shows a notice when that happens. * **CLI Tool:** diff --git a/public/editor.css b/public/editor.css index ae84467..b52d94d 100644 --- a/public/editor.css +++ b/public/editor.css @@ -84,7 +84,7 @@ a { cursor: pointer; } - .image-controls { + .image-controls, .download-controls { display: flex; flex-wrap: wrap; align-items: center; diff --git a/public/index.html b/public/index.html index d05f678..181615d 100644 --- a/public/index.html +++ b/public/index.html @@ -33,9 +33,29 @@
+

+ + + + 🖨️ Print +

- ☝️ Click the images to download. - 🖨️ Click here to print them. + ☝️ Or click an image to download just that side. +

diff --git a/src/Download.js b/src/Download.js new file mode 100644 index 0000000..ac08489 --- /dev/null +++ b/src/Download.js @@ -0,0 +1,266 @@ +import {fetchImage, sniffImageType, toDataUri} from "./ImageInfo.js"; + +/** + * Browser helpers to download the rendered diagrams as SVG or PNG files + */ + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const COMBINED_GAP = 500; // 5mm between front and back in combined downloads + +/** Resolution of PNG downloads */ +export const PNG_DPI = 300; +const MAX_PNG_PIXELS = 64000000; // stay well below browser canvas limits + +/** + * Create a file name friendly version of a title + * + * @param {string} title + * @returns {string} + */ +export function slugify(title) { + const slug = String(title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return slug || 'pinout'; +} + +/** + * Put front and back into one SVG, laid out like the printed page + * + * The back is placed below the front and rotated by 180°, so the paper can be folded + * between them with the back lining up behind the front. + * + * @param {SVGSVGElement} front + * @param {SVGSVGElement} back + * @returns {SVGSVGElement} + */ +export function combineSvgs(front, back) { + const f = viewBox(front); + const b = viewBox(back); + const width = Math.max(f.width, b.width); + const height = f.height + COMBINED_GAP + b.height; + + const root = document.createElementNS(SVG_NS, 'svg'); + root.setAttribute('xmlns', SVG_NS); + root.setAttribute('viewBox', `0 0 ${width} ${height}`); + root.setAttribute('width', `${width / 100}mm`); + root.setAttribute('height', `${height / 100}mm`); + + root.appendChild(nested(front, 0, 'front')); + + const backY = f.height + COMBINED_GAP; + const rotated = document.createElementNS(SVG_NS, 'g'); + rotated.setAttribute('transform', `rotate(180 ${b.width / 2} ${backY + b.height / 2})`); + rotated.appendChild(nested(back, backY, 'back')); + root.appendChild(rotated); + + return root; +} + +/** + * @param {SVGSVGElement} svg + * @returns {Blob} + */ +function svgBlob(svg) { + return new Blob([new XMLSerializer().serializeToString(svg)], {type: 'image/svg+xml;charset=utf-8'}); +} + +/** + * Create a self-contained SVG file, with linked images inlined where possible + * + * @param {SVGSVGElement} svg + * @returns {Promise<{blob: Blob, missingImages: string[]}>} + */ +export async function svgFile(svg) { + const copy = svg.cloneNode(true); + const missingImages = await inlineImages(copy); + return {blob: svgBlob(copy), missingImages}; +} + +/** + * Render an SVG diagram into a PNG + * + * The PNG carries its resolution, so it prints in real size just like the SVG. Images that are + * only linked are fetched and inlined first, browsers don't load external resources when + * rendering SVGs as images. + * + * @param {SVGSVGElement} svg + * @returns {Promise<{blob: Blob, missingImages: string[]}>} + */ +export async function pngFile(svg) { + const {width, height} = viewBox(svg); + let dpi = PNG_DPI; + const pixels = (value) => Math.max(1, Math.round(value / 100 / 25.4 * dpi)); // 1/100mm to pixels + if (pixels(width) * pixels(height) > MAX_PNG_PIXELS) { + dpi *= Math.sqrt(MAX_PNG_PIXELS / (pixels(width) * pixels(height))); + } + const widthPx = pixels(width); + const heightPx = pixels(height); + + const copy = svg.cloneNode(true); + copy.setAttribute('width', widthPx); + copy.setAttribute('height', heightPx); + const missingImages = await inlineImages(copy); + + const url = URL.createObjectURL(svgBlob(copy)); + try { + const image = await loadImage(url); + const canvas = document.createElement('canvas'); + canvas.width = widthPx; + canvas.height = heightPx; + const context = canvas.getContext('2d'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, widthPx, heightPx); + context.drawImage(image, 0, 0, widthPx, heightPx); + + const png = await new Promise((resolve, reject) => { + canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Could not create the PNG')), 'image/png'); + }); + const bytes = withResolution(new Uint8Array(await png.arrayBuffer()), dpi); + return {blob: new Blob([bytes], {type: 'image/png'}), missingImages}; + } finally { + URL.revokeObjectURL(url); + } +} + +/** + * Let the browser save a file + * + * @param {Blob} blob + * @param {string} filename + */ +export function saveBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** + * @param {SVGSVGElement} svg + * @returns {{x: number, y: number, width: number, height: number}} + */ +function viewBox(svg) { + const [x, y, width, height] = svg.getAttribute('viewBox').trim().split(/[\s,]+/).map(Number); + return {x, y, width, height}; +} + +/** + * Copy a diagram to be nested into a combined SVG + * + * @param {SVGSVGElement} svg + * @param {number} y Vertical position + * @param {string} suffix Appended to all ids to keep them unique in the combined document + * @returns {SVGSVGElement} + */ +function nested(svg, y, suffix) { + const copy = svg.cloneNode(true); + const {width, height} = viewBox(svg); + copy.removeAttribute('xmlns'); + copy.setAttribute('x', 0); + copy.setAttribute('y', y); + copy.setAttribute('width', width); + copy.setAttribute('height', height); + + const ids = new Map(); + copy.querySelectorAll('[id]').forEach(element => { + const id = `${element.id}-${suffix}`; + ids.set(element.id, id); + element.id = id; + }); + copy.querySelectorAll('*').forEach(element => { + for (const attribute of [...element.attributes]) { + const value = attribute.value.replace(/url\(#([^)]+)\)/g, (match, id) => ids.has(id) ? `url(#${ids.get(id)})` : match); + if (value !== attribute.value) element.setAttribute(attribute.name, value); + } + }); + + return copy; +} + +/** + * Replace linked images with data URIs + * + * @param {SVGSVGElement} svg + * @returns {Promise} The URLs of images that could not be inlined + */ +async function inlineImages(svg) { + const missing = []; + await Promise.all([...svg.querySelectorAll('image')].map(async image => { + const href = image.getAttribute('href'); + if (!href || href.startsWith('data:')) return; + try { + const bytes = await fetchImage(new URL(href, window.location.href).toString()); + image.setAttribute('href', toDataUri(bytes, sniffImageType(bytes) ?? 'application/octet-stream')); + } catch (e) { + missing.push(href); + } + })); + return missing; +} + +/** + * @param {string} url + * @returns {Promise} + */ +function loadImage(url) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('Could not render the diagram')); + image.src = url; + }); +} + +/** + * Add a pHYs chunk with the resolution to a PNG + * + * @param {Uint8Array} png + * @param {number} dpi + * @returns {Uint8Array} + */ +function withResolution(png, dpi) { + const IHDR_END = 33; // 8 bytes signature + 25 bytes IHDR chunk + const pixelsPerMeter = Math.round(dpi / 0.0254); + + const chunk = new Uint8Array(21); + const view = new DataView(chunk.buffer); + view.setUint32(0, 9); // data length + chunk.set([0x70, 0x48, 0x59, 0x73], 4); // "pHYs" + view.setUint32(8, pixelsPerMeter); + view.setUint32(12, pixelsPerMeter); + chunk[16] = 1; // unit: meter + view.setUint32(17, crc32(chunk.subarray(4, 17))); + + const result = new Uint8Array(png.length + chunk.length); + result.set(png.subarray(0, IHDR_END)); + result.set(chunk, IHDR_END); + result.set(png.subarray(IHDR_END), IHDR_END + chunk.length); + return result; +} + +let crcTable; + +/** + * @param {Uint8Array} bytes + * @returns {number} + */ +function crc32(bytes) { + if (!crcTable) { + crcTable = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; + } + crcTable[n] = c >>> 0; + } + } + let crc = 0xFFFFFFFF; + for (const byte of bytes) { + crc = crcTable[(crc ^ byte) & 0xFF] ^ (crc >>> 8); + } + return (crc ^ 0xFFFFFFFF) >>> 0; +} diff --git a/src/Editor.js b/src/Editor.js index 7aa8e01..5d84226 100644 --- a/src/Editor.js +++ b/src/Editor.js @@ -5,6 +5,7 @@ import Yaml from 'yaml'; import {Builder} from "./Builder.js"; import { ImageEmbed } from "./ImageEmbed.js"; import {imageOptionEdits} from "./YamlEdit.js"; +import {combineSvgs, pngFile, saveBlob, slugify, svgFile} from "./Download.js"; /** * Manages the Ace editor instance, handles YAML parsing, @@ -28,6 +29,14 @@ export class Editor { opacitySlider; /** @type {HTMLElement|null} Element showing the current opacity. */ opacityValue; + /** @type {HTMLSelectElement|null} Which diagrams to download: front, back, separate or combined. */ + downloadSides; + /** @type {HTMLSelectElement|null} Download format: svg or png. */ + downloadFormat; + /** @type {HTMLElement|null} Element showing download problems. */ + downloadStatus; + /** @type {string} Title of the current diagram, used for file names. */ + title = ''; /** * Creates an Editor instance. @@ -39,6 +48,10 @@ export class Editor { * @param {HTMLInputElement|null} [controls.grayscaleToggle] - Checkbox to switch the images between grayscale and color. * @param {HTMLInputElement|null} [controls.opacitySlider] - Range input (0-100) for the image opacity. * @param {HTMLElement|null} [controls.opacityValue] - Element to show the current opacity in. + * @param {HTMLSelectElement|null} [controls.downloadSides] - Select for which diagrams to download. + * @param {HTMLSelectElement|null} [controls.downloadFormat] - Select for the download format. + * @param {HTMLButtonElement|null} [controls.downloadButton] - Button to start the download. + * @param {HTMLElement|null} [controls.downloadStatus] - Element to show download problems in. */ constructor(editor, output, { pickToggle = null, @@ -46,6 +59,10 @@ export class Editor { grayscaleToggle = null, opacitySlider = null, opacityValue = null, + downloadSides = null, + downloadFormat = null, + downloadButton = null, + downloadStatus = null, } = {}) { this.output = output; this.pickToggle = pickToggle; @@ -53,6 +70,9 @@ export class Editor { this.grayscaleToggle = grayscaleToggle; this.opacitySlider = opacitySlider; this.opacityValue = opacityValue; + this.downloadSides = downloadSides; + this.downloadFormat = downloadFormat; + this.downloadStatus = downloadStatus; this.ace = ace.edit(editor); this.ace.setTheme("ace/theme/github"); @@ -79,6 +99,7 @@ export class Editor { this.grayscaleToggle?.addEventListener('change', this.onGrayscaleToggle.bind(this)); this.opacitySlider?.addEventListener('input', this.onOpacityInput.bind(this)); this.opacitySlider?.addEventListener('change', this.onOpacityChange.bind(this)); + downloadButton?.addEventListener('click', () => this.download(this.downloadSides?.value ?? 'separate')); } /** @@ -207,6 +228,7 @@ export class Editor { builder.flip(); const back = builder.build().render(window.document); + this.title = setup.title ?? ''; this.output.innerHTML = ''; embed.problems.forEach(({side, message, linked}) => { this.output.appendChild(this.message('notice', linked @@ -386,7 +408,7 @@ export class Editor { } /** - * Handles click events on the output area to trigger SVG download. + * Downloads the clicked diagram in the selected format. * @param {MouseEvent} e - The click event object. * @private */ @@ -394,19 +416,55 @@ export class Editor { const svg = e.target.closest('svg'); if (!svg) return; // Click was not on an SVG or its child - const serializer = new XMLSerializer(); - const source = serializer.serializeToString(svg); - const blob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' }); - const url = URL.createObjectURL(blob); + const index = [...this.output.querySelectorAll(':scope > svg')].indexOf(svg); + this.download(index === 1 ? 'back' : 'front'); + } + + /** + * Downloads the diagrams in the selected format + * + * @param {string} which - 'front', 'back', 'separate' (both as two files) or 'combined' (both in one file) + * @returns {Promise} + */ + async download(which) { + const [front, back] = this.output.querySelectorAll(':scope > svg'); + if (!front || !back) return; + + const format = this.downloadFormat?.value === 'png' ? 'png' : 'svg'; + const name = slugify(this.title); + const files = { + front: [[front, `${name}-front`]], + back: [[back, `${name}-back`]], + separate: [[front, `${name}-front`], [back, `${name}-back`]], + combined: [[combineSvgs(front, back), name]], + }[which] ?? []; + + this.showDownloadStatus(''); + const missing = new Set(); + try { + for (const [index, [svg, filename]] of files.entries()) { + if (index) await new Promise(resolve => setTimeout(resolve, 300)); // browsers may drop quick successive downloads + const {blob, missingImages} = await (format === 'png' ? pngFile(svg) : svgFile(svg)); + missingImages.forEach(url => missing.add(url)); + saveBlob(blob, `${filename}.${format}`); + } + } catch (e) { + console.error(e); + this.showDownloadStatus(`⚠️ Download failed: ${e.message}`); + return; + } - const a = document.createElement('a'); - a.href = url; - a.download = 'pinout.svg'; - document.body.appendChild(a); - a.click(); + if (missing.size) { + this.showDownloadStatus(`⚠️ These images could not be included in the download: ${[...missing].join(', ')}`); + } + } - document.body.removeChild(a); - URL.revokeObjectURL(url); + /** + * @param {string} text + * @private + */ + showDownloadStatus(text) { + if (this.downloadStatus) this.downloadStatus.textContent = text; } /** diff --git a/src/web.js b/src/web.js index dbc1eaa..4712c99 100644 --- a/src/web.js +++ b/src/web.js @@ -13,6 +13,10 @@ import {Editor} from "./Editor.js"; grayscaleToggle: document.getElementById('grayscale-toggle'), opacitySlider: document.getElementById('opacity-slider'), opacityValue: document.getElementById('opacity-value'), + downloadSides: document.getElementById('download-sides'), + downloadFormat: document.getElementById('download-format'), + downloadButton: document.getElementById('download-button'), + downloadStatus: document.getElementById('download-status'), } ); From b503d3f54a1b4d4f1812386e824bc14c3721951b Mon Sep 17 00:00:00 2001 From: Rodney Osodo Date: Tue, 15 Sep 2026 10:51:54 +0300 Subject: [PATCH 9/9] Support double-row pin headers Signed-off-by: Rodney Osodo --- README.md | 25 ++++++- public/index.html | 2 +- src/PhotoLayout.js | 180 +++++++++++++++++++++++++++++++++++++-------- 3 files changed, 176 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d5af25a..9fa43a7 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/ ## Limits * 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. + * Photo first layouts: headers have one or two rows, pins within a row are equally spaced along a straight line. Labels always point left, right, up or down. ## Alternatives @@ -118,10 +118,33 @@ 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. +* Double-row headers (like 2×20 GPIO headers) use `rows: 2` plus `rowStart` or `rowEnd`, the position of the second row's first or last hole. See below. * `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. +#### Double-Row Headers + +```yaml +headers: + - start: [ 300, 200 ] # first hole of the first row (pin 1) + end: [ 300, 700 ] # last hole of the first row + rows: 2 + rowEnd: [ 400, 700 ] # last hole of the second row (or rowStart for its first hole) + order: zigzag # pin numbering, see below + pins: + - [ "1", "VCC:power" ] + - [ "2", "5V:power" ] + # ... +``` + +* `pins` lists both rows. `order` sets how they are numbered: + * `zigzag` (default): pin 1 in the first row, pin 2 next to it in the second row, pin 3 below pin 1, and so on. + * `rows`: the whole first row, then the second row in the same direction. + * `around`: the whole first row, then back along the second row, like DIP chips. +* The labels of both rows point away from each other. `side` sets the side of the first row. +* On the back, `rowStart` or `rowEnd` are optional. Without them, the second row keeps the same distance to the first row as on the front. + ### Printing * Download the SVG and open it in an SVG-capable tool — your browser is fine. diff --git a/public/index.html b/public/index.html index 181615d..a6cf787 100644 --- a/public/index.html +++ b/public/index.html @@ -74,7 +74,7 @@

diff --git a/src/PhotoLayout.js b/src/PhotoLayout.js index 52a920f..e1d8364 100644 --- a/src/PhotoLayout.js +++ b/src/PhotoLayout.js @@ -4,6 +4,8 @@ const UNCALIBRATED_SIZE = 5000; // 50mm, size of the longest image side when no 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']; +const OPPOSITE = {left: 'right', right: 'left', top: 'bottom', bottom: 'top'}; +const ORDERS = ['zigzag', 'rows', 'around']; /** * Image-first layout @@ -58,55 +60,157 @@ export class PhotoLayout { /** * Validate a header definition, fill in defaults and calculate its hole positions * + * Headers have one or two rows. Holes are listed in pin order, each knowing its row and its + * index within the row. + * * @param {object} header * @param {int} index - * @returns {{side: string, pitch: number, pins: Array, holes: {x: number, y: number}[], back: {side: string|undefined, holes: {x: number, y: number}[]}|null}} + * @returns {{sides: string[], pitch: number, pins: Array, rowHoles: {x: number, y: number}[], rowOffset: {x: number, y: number}|null, holes: {x: number, y: number, row: int, index: int}[], back: {sides: string[]|null, holes: Array<{x: number, y: number}|null>, rowHoles: {x: number, y: number}[]}|null}} */ normalizeHeader(header, index) { const name = `Header ${index + 1}`; + header = header ?? {}; // pins can be given as a single label string for convenience - let pins = (header?.pins ?? []).map(pin => { + 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; + const rows = header.rows ?? 1; + if (rows !== 1 && rows !== 2) { + throw new Error(`${name}: rows must be 1 or 2`); + } + const order = header.order ?? 'zigzag'; + if (!ORDERS.includes(order)) { + throw new Error(`${name}: order must be one of ${ORDERS.join(', ')}`); + } + + 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 perRow = Math.ceil(count / rows); + pins = pins.concat(Array(perRow * rows - 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(', ')}`); - } + const rowHoles = this.holePositions(header, perRow, name); + const rowOffset = rows === 2 ? this.rowOffset(header, rowHoles, name) : null; + const layout = pins.map((_, i) => this.pinPlace(i, perRow, rows, order)); + const holes = layout.map(({row, index}) => ({ + x: rowHoles[index].x + (row ? rowOffset.x : 0), + y: rowHoles[index].y + (row ? rowOffset.y : 0), + row, + index, + })); + + const sides = this.rowSides(header.side, rowHoles, rowOffset, name); let back = null; if (header.back) { if (!this.back.src) { throw new Error(`${name}: back coordinates need a back image (image.back.src)`); } + const backRowHoles = this.holePositions(header.back, perRow, `${name} (back)`); + const hasRowPosition = header.back.rowStart !== undefined || header.back.rowEnd !== undefined; + const backOffset = rows === 2 && hasRowPosition ? this.rowOffset(header.back, backRowHoles, `${name} (back)`) : null; back = { - side: header.back.side, - holes: this.holePositions(header.back, count, `${name} (back)`), + // without a second row position on the back, the second row is placed relative to the first one later + holes: layout.map(({row, index}) => { + if (!row) return backRowHoles[index]; + if (!backOffset) return null; + return {x: backRowHoles[index].x + backOffset.x, y: backRowHoles[index].y + backOffset.y}; + }), + rowHoles: backRowHoles, + sides: header.back.side === undefined ? null : this.rowSides(header.back.side, null, null, `${name} (back)`), }; - if (back.side !== undefined && !SIDES.includes(back.side)) { - throw new Error(`${name} (back): side must be one of ${SIDES.join(', ')}`); - } } return { - side, + sides, pitch: header.pitch ?? this.pitch, pins, + rowHoles, + rowOffset, holes, back, }; } + /** + * Where the n-th pin of a header sits + * + * zigzag: pin 1 in the first row, pin 2 next to it in the second row, and so on (like most 2×N headers) + * rows: all pins of the first row, then all pins of the second row in the same direction + * around: all pins of the first row, then back along the second row (like DIP chips) + * + * @param {int} pin Index of the pin + * @param {int} perRow Number of holes per row + * @param {int} rows Number of rows + * @param {string} order + * @returns {{row: int, index: int}} + */ + pinPlace(pin, perRow, rows, order) { + if (rows === 1) return {row: 0, index: pin}; + if (order === 'zigzag') return {row: pin % 2, index: Math.floor(pin / 2)}; + const row = pin < perRow ? 0 : 1; + if (!row) return {row, index: pin}; + return {row, index: order === 'around' ? 2 * perRow - 1 - pin : pin - perRow}; + } + + /** + * The distance from the first to the second row of a double-row header + * + * @param {object} coordinates Object with rowStart (the second row's first hole) or rowEnd (its last hole) + * @param {{x: number, y: number}[]} rowHoles Holes of the first row + * @param {string} name Header name for error messages + * @returns {{x: number, y: number}} + */ + rowOffset(coordinates, rowHoles, name) { + const isPoint = (p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite); + let offset; + if (isPoint(coordinates.rowStart)) { + offset = {x: coordinates.rowStart[0] - rowHoles[0].x, y: coordinates.rowStart[1] - rowHoles[0].y}; + } else if (isPoint(coordinates.rowEnd)) { + const last = rowHoles[rowHoles.length - 1]; + offset = {x: coordinates.rowEnd[0] - last.x, y: coordinates.rowEnd[1] - last.y}; + } else { + throw new Error(`${name}: double-row headers need rowStart or rowEnd, the pixel coordinates [x, y] of the second row's first or last hole`); + } + if (!offset.x && !offset.y) { + throw new Error(`${name}: the second row can't be at the same position as the first`); + } + return offset; + } + + /** + * Where the labels of each row are drawn + * + * Single rows point away from the board center. Double rows point away from each other, + * an explicit side applies to the first row and the second row gets the opposite side. + * + * @param {string|undefined} side Explicitly configured side + * @param {{x: number, y: number}[]|null} rowHoles Holes of the first row, needed when guessing + * @param {{x: number, y: number}|null} rowOffset Offset of the second row, null for single rows + * @param {string} name Header name for error messages + * @returns {string[]} Side for the first and second row + */ + rowSides(side, rowHoles, rowOffset, name) { + if (side === undefined) { + if (!rowOffset) { + side = this.guessSide(rowHoles); + } else if (Math.abs(rowOffset.x) >= Math.abs(rowOffset.y)) { + side = rowOffset.x > 0 ? 'left' : 'right'; + } else { + side = rowOffset.y > 0 ? 'top' : 'bottom'; + } + } + if (!SIDES.includes(side)) { + throw new Error(`${name}: side must be one of ${SIDES.join(', ')}`); + } + return [side, OPPOSITE[side]]; + } + /** * Space holes equally between the start and end coordinates * @@ -164,10 +268,10 @@ export class PhotoLayout { 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); + if (header.rowHoles.length < 2) return; + const first = header.rowHoles[0]; + const last = header.rowHoles[header.rowHoles.length - 1]; + const pixelPitch = Math.hypot(last.x - first.x, last.y - first.y) / (header.rowHoles.length - 1); estimates.push({index, scale: header.pitch * PINSPACE / 2.54 / pixelPitch}); }); @@ -217,10 +321,10 @@ export class PhotoLayout { const pairs = this.headers .filter(header => header.back) - .flatMap(header => header.holes.map((hole, i) => ({ - from: header.back.holes[i], - to: this.mirrorHole(hole), - }))); + .flatMap(header => header.holes + .map((hole, i) => ({from: header.back.holes[i], to: this.mirrorHole(hole)})) + .filter(({from}) => from) + ); const distinct = new Set(pairs.map(({from}) => `${from.x},${from.y}`)); if (distinct.size < 2) { @@ -270,6 +374,7 @@ export class PhotoLayout { this.headers.forEach((header, index) => { if (!header.back) return; const offset = Math.max(...header.holes.map((hole, i) => { + if (!header.back.holes[i]) return 0; 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); @@ -299,15 +404,12 @@ export class PhotoLayout { 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 {x: hole.x * this.scale, y: hole.y * this.scale, side: header.sides[hole.row], labels: header.pins[i]}; } - const position = header.back - ? this.transformPoint(this.backMatrix, header.back.holes[i]) - : this.mirrorHole(hole); return { - ...position, - side: header.back?.side ?? mirroredSide[header.side] ?? header.side, + ...this.backHole(header, hole, i), + side: header.back?.sides?.[hole.row] ?? mirroredSide[header.sides[hole.row]] ?? header.sides[hole.row], labels: header.pins[i], }; }) @@ -315,6 +417,26 @@ export class PhotoLayout { ); } + /** + * Position of a hole in the back view + * + * @param {object} header Normalized header + * @param {{x: number, y: number, row: int, index: int}} hole Front hole + * @param {int} i Pin index + * @returns {{x: number, y: number}} + */ + backHole(header, hole, i) { + if (!header.back) return this.mirrorHole(hole); + if (header.back.holes[i]) return this.transformPoint(this.backMatrix, header.back.holes[i]); + + // second row without back coordinates: same distance to the first row as on the front + const first = this.transformPoint(this.backMatrix, header.back.rowHoles[hole.index]); + return { + x: first.x - header.rowOffset.x * this.scale, + y: first.y + header.rowOffset.y * this.scale, + }; + } + /** * Pin definitions in the format the legend expects *