diff --git a/README.md b/README.md index 05e1148..9fa43a7 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 `https://` URLs. * **Configuration:** * Define pinouts, board dimensions, labels, and types using simple YAML or JSON files. @@ -24,7 +25,9 @@ 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. - * Download generated SVG diagrams. + * 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 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:** * Process multiple configuration files or entire directories to generate SVG diagrams programmatically. @@ -32,15 +35,19 @@ 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. + * Front and back photos can be placed independently. + ## 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: 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 @@ -69,6 +76,75 @@ 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" + back: + src: "SIM7080G-back-pcb.png" + +headers: + - 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" ] + - [ "RTS:uart" ] + - [ ] # a hole without labels + - [ ] + - [ ] + - [ "3V3:power" ] + - [ "GND:gnd" ] +``` + +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. + +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. +* 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/editor.css b/public/editor.css index 53c3f8e..b52d94d 100644 --- a/public/editor.css +++ b/public/editor.css @@ -83,13 +83,46 @@ a { border: 1px solid #ccc; cursor: pointer; } + + .image-controls, .download-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em 2em; + + label { + display: inline-flex; + align-items: center; + gap: 0.5em; + } + + output { + min-width: 4em; + } + } + + #output.picking svg { + cursor: crosshair; + } + + #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/public/index.html b/public/index.html index 16158b4..a6cf787 100644 --- a/public/index.html +++ b/public/index.html @@ -33,9 +33,50 @@
+

+ + + + 🖨️ Print +

+

+ ☝️ Or click an image to download just that side. + +

+ +

+ + +

+

- ☝️ 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/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 77f8e8a..5d84226 100644 --- a/src/Editor.js +++ b/src/Editor.js @@ -4,6 +4,8 @@ import 'ace-builds/src-noconflict/theme-github'; 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, @@ -17,14 +19,60 @@ 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; + /** @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; + /** @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. * @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. + * @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) { + constructor(editor, output, { + pickToggle = null, + pickStatus = null, + grayscaleToggle = null, + opacitySlider = null, + opacityValue = null, + downloadSides = null, + downloadFormat = null, + downloadButton = null, + downloadStatus = null, + } = {}) { this.output = output; + this.pickToggle = pickToggle; + this.pickStatus = pickStatus; + 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"); @@ -42,7 +90,16 @@ 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 = ''; + }); + 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')); } /** @@ -52,6 +109,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()) { @@ -129,6 +187,7 @@ export class Editor { prettyErrors: true, }); localStorage.setItem(this.STORAGE_KEY, yaml); + this.updateImageControls(parsed); this.onUpdate(parsed); this.clearErrors(); } catch (e) { @@ -156,19 +215,200 @@ 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 = ''; + this.output.appendChild(this.message('error', e.message)); + return; + } const front = builder.build().render(window.document); 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 + ? `⚠️ 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); } /** - * Handles click events on the output area to trigger SVG download. + * @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 + * + * 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 + */ + updateImageControls(setup) { + const images = ['front', 'back'] + .map(side => setup?.image?.[side]) + .filter(image => image?.src); + + if (this.grayscaleToggle) { + const grayscale = images.map(image => Boolean(image.grayscale ?? true)); + this.grayscaleToggle.disabled = images.length === 0; + 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; + } + + /** + * Sets the grayscale option of all images in the YAML + * @private + */ + onGrayscaleToggle() { + if (!this.setImageOption('grayscale', this.grayscaleToggle.checked)) { + this.grayscaleToggle.checked = !this.grayscaleToggle.checked; // can't edit broken YAML, undo the click + } + } + + /** + * 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 + * + * Only the affected lines are changed, so formatting, comments and undo history are kept. + * + * @param {string} option + * @param {boolean|number} value + * @returns {boolean} false if the YAML could not be edited + * @private + */ + setImageOption(option, value) { + const session = this.ace.session; + let edits; + try { + edits = imageOptionEdits(session.getValue(), option, value); + } catch (e) { + return false; + } + + for (const edit of edits) { + const start = session.doc.indexToPosition(edit.start); + const end = session.doc.indexToPosition(edit.end); + session.replace({start, end}, edit.text); + } + return true; + } + + /** + * 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. + * 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} + * @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()); + 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: round(point.x), + y: 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} ]`); + } + + /** + * Downloads the clicked diagram in the selected format. * @param {MouseEvent} e - The click event object. * @private */ @@ -176,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] ?? []; - const a = document.createElement('a'); - a.href = url; - a.download = 'pinout.svg'; - document.body.appendChild(a); - a.click(); + 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; + } - document.body.removeChild(a); - URL.revokeObjectURL(url); + if (missing.size) { + this.showDownloadStatus(`⚠️ These images could not be included in the download: ${[...missing].join(', ')}`); + } + } + + /** + * @param {string} text + * @private + */ + showDownloadStatus(text) { + if (this.downloadStatus) this.downloadStatus.textContent = text; } /** diff --git a/src/ImageEmbed.js b/src/ImageEmbed.js index 9c14ffb..6415c8b 100644 --- a/src/ImageEmbed.js +++ b/src/ImageEmbed.js @@ -1,11 +1,13 @@ +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; /** * 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 { /** @@ -20,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 = []; } /** @@ -30,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) { @@ -63,10 +78,35 @@ 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 (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); + } + })); + return setup; // Return the modified setup object } @@ -82,6 +122,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); @@ -92,9 +135,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. @@ -106,47 +148,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) { @@ -160,10 +182,35 @@ 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) { + 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); + } + + 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. + * 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. + * @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); + 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}`); + 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(); @@ -175,4 +222,28 @@ export class ImageEmbed { return dataUri; } + /** + * @param {string} url + * @returns {Promise} + * @private + */ + async fetchBytes(url) { + return fetchImage(url); + } + + /** + * @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..6b1b463 --- /dev/null +++ b/src/ImageInfo.js @@ -0,0 +1,306 @@ +/** + * 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', +]; + +/** 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 + * + * 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 { + 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' || e.status) 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/PhotoLayout.js b/src/PhotoLayout.js new file mode 100644 index 0000000..e1d8364 --- /dev/null +++ b/src/PhotoLayout.js @@ -0,0 +1,519 @@ +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']; +const OPPOSITE = {left: 'right', right: 'left', top: 'bottom', bottom: 'top'}; +const ORDERS = ['zigzag', 'rows', 'around']; + +/** + * 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%. + * + * 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 { + + /** + * @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)'); + } + 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(); + this.sheetWidth = this.front.naturalWidth * this.scale; // the axis we mirror the back side on + 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 + * + * 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 {{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 => { + if (pin === null || pin === undefined) return null; + return (Array.isArray(pin) ? pin : [pin]).map(String); + }); + + 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); + } + const perRow = Math.ceil(count / rows); + pins = pins.concat(Array(perRow * rows - pins.length).fill([])); + + 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 = { + // 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)`), + }; + } + + return { + 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 + * + * @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.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}); + }); + + 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 + * + * 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 + */ + fitBack() { + const s = this.scale; + if (!this.back.src) { + return [-s, 0, 0, s, this.sheetWidth, 0]; + } + + const pairs = this.headers + .filter(header => header.back) + .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) { + 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) => { + 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); + })); + 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; + } + + /** + * 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.sides[hole.row], labels: header.pins[i]}; + } + + return { + ...this.backHole(header, hole, i), + side: header.back?.sides?.[hole.row] ?? mirroredSide[header.sides[hole.row]] ?? header.sides[hole.row], + labels: header.pins[i], + }; + }) + .filter(Boolean) + ); + } + + /** + * 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 + * + * @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/YamlEdit.js b/src/YamlEdit.js new file mode 100644 index 0000000..0264d30 --- /dev/null +++ b/src/YamlEdit.js @@ -0,0 +1,60 @@ +import Yaml from 'yaml'; + +/** + * 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. + * + * @param {string} text The YAML source + * @param {string} option The image option to set + * @param {boolean|number} newValue The new value + * @returns {{start: number, end: number, text: string}[]} Edits as character offsets, sorted from last to first + * @throws {Error} If the YAML can't be parsed + */ +export function imageOptionEdits(text, option, newValue) { + const doc = Yaml.parseDocument(text); + if (doc.errors.length) { + throw doc.errors[0]; + } + + const value = String(newValue); + const edits = []; + + for (const side of ['front', 'back']) { + const image = doc.getIn(['image', side], true); + if (!Yaml.isMap(image) || !image.get('src')) continue; + + const pairs = image.items; + const existing = pairs.find(pair => pair.key?.value === option); + if (existing?.value?.range) { + const [start, end] = existing.value.range; + edits.push({start, end, text: value}); + continue; + } + + const src = pairs.find(pair => pair.key?.value === 'src'); + if (image.flow) { + const end = src.value.range[1]; + edits.push({start: end, end, text: `, ${option}: ${value}`}); + } else { + const keyStart = src.key.range[0]; + const lineStart = text.lastIndexOf('\n', keyStart - 1) + 1; + const indent = text.slice(lineStart, keyStart).replace(/\S/g, ' '); + let lineEnd = text.indexOf('\n', src.value.range[1]); + if (lineEnd === -1) lineEnd = text.length; + edits.push({start: lineEnd, end: lineEnd, text: `\n${indent}${option}: ${value}`}); + } + } + + return edits.sort((a, b) => b.start - a.start); +} + +/** + * @param {string} text The YAML source + * @param {{start: number, end: number, text: string}[]} edits Edits sorted from last to first + * @returns {string} + */ +export function applyEdits(text, edits) { + return edits.reduce((result, edit) => result.slice(0, edit.start) + edit.text + result.slice(edit.end), text); +} 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..4712c99 100644 --- a/src/web.js +++ b/src/web.js @@ -6,7 +6,18 @@ 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'), + 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'), + } );