mesh-avatar-studio
Turn one illustration into an animated 2D mesh avatar with a coding agent and a local editor
FigureLab is a single HTML file for assembling publication-quality microscopy figures. No installation, no server, and your images never leave your machine.
DesignHTML · JavaScriptGitHub @mbaffourOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Browser-based figure assembly tool for researchers. No installation — open and use.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
A single HTML file for assembling publication-quality scientific figures. No installation, no server, no internet required — open in any browser and start working.
Works for microscopy images, western blots, histology, gels, clinical photos, or any image-based figure.
Built with passion for science and discovery.
A figure assembled and exported entirely in the browser (via ✨ Load example figure):Read the full README ↗A single HTML file for assembling publication-quality scientific figures. No installation, no server, no internet required — open in any browser and start working.
Works for microscopy images, western blots, histology, gels, clinical photos, or any image-based figure.
Built with passion for science and discovery.
A figure assembled and exported entirely in the browser (via ✨ Load example figure):
figure_lab.html in your browser (Chrome or Firefox recommended)The sidebar is organised by what you're doing, in the order you do it:
Images → Arrange → Templates → Crop & Scale → Annotate & Draw → Look → Measure → Check → Export
Every tool is visible — there is no mode that hides features. Sections collapse to keep the list short, and deeper tools sit grouped under a "More" rule within their section rather than disappearing. Press Ctrl/⌘+K to search every tool by name.
Tools that cannot work in your current layout mode are hidden or disabled with the reason shown, rather than accepting a click and doing nothing.
Earlier versions had a Simple/Advanced switch. It hid about thirty-five controls, including whole tools like Batch Crop, which made them impossible to discover — you cannot look for something you have no way of knowing exists. It was removed in v3.9.3.
FigureLab's privacy rule is simple: your experimental images never leave your machine, under any feature. Three AI features exist, each with a different (and clearly stated) network story:
AI never touches your data panels. Generation only ever inserts a new schematic object; there is no inpainting or AI retouching of imported experimental images, and exports state this in their metadata.
The caption helper can optionally polish your figure legend with AI that runs entirely in your browser, on your own machine — no server, no API key, nothing uploaded. It uses Chrome's built-in Prompt API (Gemini Nano), the browser-native on-device model, so it fits FigureLab's offline/privacy promise exactly.
window.LanguageModel), FigureLab silently keeps the built-in rule-based caption — the feature never blocks anything or requires a network.Why not Google AI Edge / MediaPipe LLM Inference directly? That path can run open models (Gemma) in-browser via a WASM runtime, but it means bundling a multi-megabyte runtime + model weights, which breaks the "single HTML file, no dependencies" identity. The Chrome built-in Prompt API gives the same on-device, private benefit with zero bundled dependencies — the right trade-off here. (If you ever want a fully browser-agnostic option, a MediaPipe/Transformers.js build could be offered as a separate opt-in bundle.)
| Feature | Details |
|---|---|
| Grid layout | Rows × columns with independent horizontal and vertical gap control |
| Drag-to-space | Hover between panels on the canvas and drag the divider (or tap + / −) to space them out live |
| Per-gutter spacing | Turn on Per-gutter spacing to give individual rows/columns their own gap; Reset gutters returns to uniform |
| Per-panel crop | Trim each edge by % — or use the interactive crop editor |
| Panel labels | Format as A/B/C, a/b/c, 1/2/3, or i/ii/iii — with position, size, colour, font, bold/italic |
| Panel border | Optional border around each panel (width + colour) |
| Scale bars | Per-panel enable/disable, µm/nm/mm auto-formatted label |
| Axis labels | Optional row and column headers with custom text |
| Figure title | Optional title rendered above the figure |
| Journal presets | One-click settings for Nature, Science, Cell, and Poster |
| Light / dark mode | Toggled from the header |
| Control | Details |
|---|---|
| Brightness / Contrast | Sliders with live preview |
| Gamma correction | Nonlinear tone adjustment — useful for dim fluorescence images |
| Grayscale / Invert | One-click toggles |
| Black / White point | Clip and stretch the intensity range |
| Fluorescence LUTs | Fire, Green, Cyan, Magenta, Yellow, Blue |
| Multi-channel composite | Add extra channels with independent LUTs (screen blend) |
| Rotate | 90° CCW, CW, 180° |
| Flip | Horizontal and vertical |
| Lock panel | Prevents accidental drag-reorder |
Click 🔬 Calibrate from image inside any panel's settings. Click two known points on the image, enter the real-world distance, and the µm/px ratio is calculated automatically. Objective presets (4× – 100×) are also available.
Auto scale bar: click the Auto SB button on any panel to automatically pick a clean scale bar length (≈ 20% of panel width).
Draw directly on the rendered figure:
Editing annotations:
Drawing tips:
Undo / Redo — full history for all annotation actions
Measurements read the native samples of 16-bit/float TIFFs where an exact mapping exists, and the 8-bit display otherwise — always stating which.
Raw vs display. Import a 16-bit or 32-bit float TIFF and FigureLab keeps the decoder's native samples in memory next to the 8-bit display image; measurements then read those, giving absolute intensity at the acquisition bit depth, areas counted in source pixels, and true sensor saturation — all before any brightness/contrast/gamma/LUT. For 8-bit sources, PNG/JPEG imports, JPEG-compressed TIFFs, or a reloaded session, measurements read the rendered 8-bit canvas and say so.
Reading raw values means inverting the panel's placement (crop → contain-fit or stretch) back to a source pixel. Where that can't be exact — a rotated or flipped panel, a circle/ellipse crop, or a multi-channel composite — FigureLab deliberately falls back to the disclosed display measurement rather than risk a wrong correspondence. Every result and every CSV row states which pixels produced it.
Not a substitute for FIJI/CellProfiler. These are simple, documented operations for sanity checks and for reporting values in a figure — real segmentation and quantitative pipelines belong in a dedicated analysis tool, and the result panel says so.
A freeform element that draws DNA constructs scaled by base pairs.
name, type, start, end, strand; tab, comma, or two-space separated), add features one at a time from a glyph dropdown, or drop a GenBank/FASTA file onto the canvas⤢ Rescale… to multiply every coordinate so a construct keeps its proportions at a new size<polyline>/<rect> glyphs and live <text> in SVG, redrawn at target DPI for rasterGenBank import reads the LOCUS length, the circular/linear flag, and the FEATURES table (/label, /gene, /product for names; complement() for the reverse strand). The sequence is ignored, the record-wide source feature is skipped, and a join() is drawn as its overall span — spliced exon structure is not represented. Nothing is inferred: the map shows what the file declares.
| Action | How |
|---|---|
| Zoom in/out | Ctrl + scroll wheel, or +/- buttons |
| Pan | Space + drag |
| Zoom to fit | ⊡ Fit button |
| Zoom to 100% | 1:1 button |
| Copy figure | ⎘ Copy button — copies to clipboard as PNG |
| Reset zoom | Click the 100% zoom button |
| Format | Notes |
|---|---|
| PNG | Lossless, DPI metadata embedded via pHYs chunk |
| JPEG | Smallest file size |
| WebP | Good balance of quality and size |
| TIFF | Uncompressed RGB, scaled to target DPI — journals prefer this |
| Image at correct physical size, with figure labels as real embedded text (selectable, searchable, sharp at any zoom; base-14 fonts, nothing bundled) | |
| SVG | Background raster + vector annotations as native SVG elements |
Set a file name, a printed width (journal column widths in mm), and a DPI (72 / 150 / 300 / 600) before exporting. With a target width set, the export lands at exactly that physical size at the chosen DPI; without one it uses DPI/96. TIFF and PNG are supersampled to the true pixel count, not merely DPI-tagged.
The info bar below the canvas shows pixel dimensions and physical size in mm, and the export panel reports the true point size your panel labels will print at.
Click ⬇ CSV Metadata to export a spreadsheet of all panel settings — name, label, µm/px, scale bar, brightness, gamma, crop, caption, and metadata notes. Useful for methods sections and lab records.
Export an R script (uses magick) or Python script (uses Pillow + matplotlib) that recreates the figure from your original image files.
Add a short note to each image in its settings panel, then click ✦ Generate Caption to produce a structured journal-style caption:
Figure. Effect of treatment. (A) Control cells. Scale bar, 10µm. (B) Treated cells. Scale bar, 10µm.
Save the entire figure — layout, per-panel settings, annotations, notes, and the image pixels — as a JSON file and reload it later to restore everything exactly, no re-dropping required. (Sessions saved by very old versions, before pixel data was embedded, will prompt you to re-drop those images.)
Expand the Export panel to find a Figure notes text area — write antibody dilutions, reviewer comments, or anything else that should travel with the session file.
| Key | Action |
|---|---|
| R | Render figure |
| Esc | Exit tool / deselect |
| Ctrl + Z | Undo |
| Ctrl + Y | Redo |
| Ctrl + Shift + Z | Redo (alternate) |
| Ctrl + S | Save session JSON |
| Space + drag | Pan canvas |
| Shift + draw | Snap to 45° / square |
display pixels = (µm length ÷ µm/px) × (display width ÷ original width)
Objective presets (approximate):
| Objective | µm/px |
|---|---|
| 4× | 1.62 |
| 10× | 0.65 |
| 20× | 0.32 |
| 40× | 0.16 |
| 60× | 0.108 |
| 100× | 0.065 |
gap pill appears. Drag it (or tap +) to push them apart. Drag a horizontal seam for row spacing, a vertical seam for column spacing.pHYs chunk plus sRGB/gAMA and reproducibility metadata — the DPI reads correctly in Photoshop / ImageJ/DCTDecode) and lossless Flate (/FlateDecode, the default in the submission package). Panel labels and scale-bar text are laid down as real, selectable PDF text over the raster; the /MediaBox is the true physical page size. Multi-page PDF is the same writer with more than one page: the same supersampled canvas, the same page size, the same lossless option (on by default) and the same text layer on every page.<text> for every label; width/height carry the printed width while the viewBox stays in logical unitsBuilt by making real figures end to end and fixing every place the work stalled, then reviewing those fixes adversarially and fixing what the review found — plus six features for getting a figure through submission: a Methods paragraph, per-panel print resolution, LZW TIFF, merging ticked channel files, a split that keeps the field, and repeating an annotation across panels.
New: every panel shows the resolution it actually prints at. The DPI check was about the whole canvas. A 512 px confocal field or a tight inset stretched across a column exported at '300 DPI' while the panel itself was 90 ppi of interpolated pixels, and the report said ✓. Each image-list row now reads '412 ppi' or '180 ppi · enlarged 1.7×'. The compliance check (with a Select panel fix) and the Save dialog name any panel below 300 ppi. The FAQ no longer claims that a higher DPI adds detail to a picture that has none.
New: ✎ Write Methods paragraph. Journals want the image processing described in the Methods: the software and its version, whether adjustments were applied uniformly, any non-linear gamma, pseudocolour, resampling, projections and where the scale came from. FigureLab knew every one of these and told you to type them yourself. It now writes the paragraph from the figure's own state, using the same test the caption uses so the two cannot disagree, and puts it in the submission package as -methods.txt. Splice markers and projections now record what they are, so the paragraph can say so.
New: LZW-compressed TIFF. TIFF export was uncompressed only, and PLOS asks for LZW-compressed TIFF. The Save dialog and the Export panel now offer Compress (LZW, lossless), the PLOS preset switches it on, and the submission package follows the same setting. The pixels come back bit for bit, checked with FigureLab's own reader and independently with Pillow/libtiff (tests/validate_tiff.py). How much smaller the file gets depends on the picture: a figure with white background and flat panels went from 2.4 MB to 0.8 MB, while pure noise grows by about a third. The default uncompressed TIFF is unchanged byte for byte.
Splitting a merge keeps the field you framed. The single-channel panels were built from scratch and inherited only the calibration. A merge you had cropped and turned therefore sat beside DAPI and GFP panels showing the whole uncropped, unturned frame: a row comparing two different fields while looking like one. The parts now carry the merge's crop, tilt, crop shape, rotation, flips, scale bar, intensity bar and acquisition note. An inset linked to the merged panel stays linked. The split letters the panels you can see first and hidden ones after them, and a figure with a blank cell no longer throws when you split.
New: combine ticked panels into one merge. Separately exported channels (C1-DAPI.tif, C2-GFP.tif…) could only be merged by re-picking each file from disk inside one panel's drawer, and in Chrome that route did nothing at all with a TIFF: no channel, no message. Tick the panels and press ⊕ Merge channels. The first becomes the base and keeps its letter, the others become its channels coloured from their filenames, and the originals are hidden rather than deleted and lettered after the visible panels. One undo reverses the whole merge. now opens TIFFs too, says so when it cannot open a file, and can be undone.
Three things people went looking for and could not find, and several places the interface said something that was not true.
dpi/96, which is the scale only when no printed width is chosen. With 89 mm at 300 DPI it announced 2131 × 2069 px / 180 × 175 mm for a file that is really 1051 × 1020 px / 89 × 86 mm — 2.03× out, on the last screen you see before the file is written. It now uses the same two helpers the canvas readout and the compliance check already used.Channels, time, and three labels that looked draggable and were not.
Inset outlines are vector in the SVG export. The outline on the parent, its letter and the connector lines are held out of the raster and written as <rect>, <text> and <line>, so they stay editable and never pixelate — the same treatment the labels have always had.
✛ Axes annotation: an orientation marker for histology and embryo panels — two crossed arrows lettered D V A P by default; select it and type four letters (R C M L, S I A P…) to change them. Placed with a click, on a panel or the figure, exported as vector.
Counter markers now reach the SVG export. They were dropped entirely: the export empties the annotation list for the raster and re-emitted only the types it knew.
Sync display range by channel (a panel's settings, and ⌘K): the reference panel's black/white points and gamma go to every other panel and extra channel that shows the same channel — DAPI levels to every DAPI, GFP to every GFP — and nothing else changes, not the LUT and not a panel showing a different channel. What QuickFigures and OMERO.figure call synchronising the rendering settings, and what "identical display settings" in a legend has to mean. One undo step.
▤ Key annotation: a channel key for the figure — every channel the visible panels show, once, each with its colour swatch, derived at draw time so it cannot fall out of step with the panels; placed with a click, background and text editable, exported as vector.
Black and white points for every extra channel, as two small sliders under each channel in the panel's channel list. The values existed and were read by the composite and written by Sync by channel, but nothing in the interface could set them.
Compliance: display ranges by channel. A warning row when compared panels show the same channel at different black–white ranges — "GFP: A 10–200, B 0–255" — with Sync by channel one click away. Compared panels should share a range, or the legend should say why not.
A quiet empty figure. With no images loaded, the Images section showed a wall of inert panel tools — sort, channel detection, subsets, a red Delete hidden panels — on a figure that was still empty. They now appear once there is something for them to act on, and one line says so: the app's existing "hidden with the reason shown" rule applied to the empty case. The IA guard test probes it that way.
Command palette input restored to full width. The global input[type=text]{width:76px} rule outranks a bare class selector, so the palette box was 76 px wide and its placeholder read "Type a co…".
A feature release, two days after the audit: cropping, insets, panel tags, and a handful of things QuickFigures, OMERO.figure and FigureJ had that FigureLab did not.
Focus: correctness, from an independent audit. No new features — but two of these reached files that go to journals, so re-export and re-read anything you made with a column width set.
183 mm — double column) at 300 DPI produced a PNG whose pHYs chunk said 210 dpi, a TIFF tagged the same, and a PDF /MediaBox 261 mm wide — 43 % oversize and below the ≥300 dpi bar FigureLab's own compliance panel had just reported as cleared. The on-screen readout said "183 mm, 300 DPI" throughout. Every column preset was affected and the error grew as the column narrowed (89 mm at 600 dpi was 168 % too large). The cause was one expression: the effective DPI was computed as scale × 96, which is the resolution only when no printed width is set. It is now derived from the physical width the canvas actually has. Re-export any figure made with a column width selected.PANEL_HISTORY.txt; it just never reached the artefact that goes to the journal.dpi/96 while the canvas was rendered at the printed-width scale, so under a column width some panels were cropped past the right edge of the canvas (blank) and others took in their neighbour. And any artifact that threw — a TIFF or lossless PDF on the 60 MP path — was dropped from the ZIP silently while the toast still said "ready". Crops now use the scale the canvas was actually rendered at, and anything missing is named on screen and in the package's own . PowerPoint export had the same crop bug and is fixed with it.Focus: the duplication check journals actually run, and the attribution-required icon tranche the Credits machinery was built for.
CREDITS.txt ships in the submission package. A test places a real Servier icon and asserts the real credit line comes out. Library now 210 icons. NIH BioArt was investigated and skipped: it is a JS app with no public API, and scraping its internal endpoints is neither stable nor clearly sanctioned.tools/bioicons-ccby-pack.json regenerates the tranche through tools/build-icons.mjs, same review-then-paste workflow, share-alike still rejected outright.Focus: the icon library, doubled with imported artwork — and the licence discipline to make that safe.
mono:false, are placed as drawn, and the colour control hides for them rather than pretending to work.xmlns: declarations while leaving <sodipodi:namedview/> behind produced an undeclared namespace prefix — an XML parse error, so 23 of 86 icons failed to load at all.class="…" severed the <style> blocks these SVGs keep their colours in, so the artwork rendered as flat black silhouettes while still parsing fine. Pills, viruses and plasmids all came through as blobs.
Neither would have been caught by reading the files. The import now renders each icon, measures its ink, and rejects a "colour" icon that comes out in one tone.tools/build-icons.mjs carries both fixes and documents why id and class must survive cleanup.Focus: AI that edits schematics and never data, plus the features a competitor survey said were worth having.
_aiEditable) decides every entry point so the rule cannot drift between the toolbar, the palette and the layer list, and a test asserts refused paths issue zero network requests. Editing a hand-drawn shape retags it AI-generated then and there, because that is when the disclosure obligation starts.CREDITS.txt into the submission package. FigureLab's own icons need no attribution at all: no publication licence, no watermark, no per-figure fee. tools/build-icons.mjs imports third-party packs under a licence allowlist that rejects share-alike outright.Focus: generating figure artwork with Gemini, using your own account rather than an API key — and making the existing AI path honest.
file:// origins can't be registered at all), and routing a consumer Gemini session through a third-party app violates Google's terms — they began suspending accounts for it in March 2026. Handing the prompt to the user's own tab is the honest maximum, and it's the only route that spends the free consumer usage.gemini-2.0-flash-exp — retired — so it could not have succeeded for some time. Now on the current /v1/…:generateContent endpoint with the Nano Banana family selectable (Flash, Flash Lite, Pro), aspect ratio, 1K/2K/4K output, cancel mid-flight, and an error for each real failure: offline (named before the call, not surfaced as Failed to fetch), bad key, rate/spend cap, and the one every new user hits — creating a key is free, but Google removed the free tier for image models in 2026, so generation needs billing linked. The old "Get free key" text said the opposite.generateAIImage with a wrapper that awaited "the original", but both declarations hoist, so it captured itself — every click was infinite async recursion and the spinner never stopped. Also: inserting an AI image skipped pushUndo (Ctrl+Z undid the wrong thing); the provenance engine string didn't match the model actually called; the backend choice never persisted despite being read at startup; lightweight session saves discarded AI image data that cannot be re-imported; and the API key moved to the fl- settings convention with a one-time migration.Focus: crops that aren't square to the camera, and multi-crop sessions you can correct without starting over.
× drops it outright. Leaving the name blank on update keeps the name it already had, so repositioning never silently strips a label.CropAngle and Resampling columns, the provenance hash covers it, and the deep audit names the tilted panels.Note on the v3.9.4 archive. The Zenodo record for v3.9.4 contains a
README.mdwith ~200 double-encoded characters, from a PowerShell round-trip during that release.figure_lab.htmlin that archive is unaffected, and every machine-readable citation field is correct. The tag was deliberately left alone so it and its DOI describe the same bytes. This release supersedes it with a clean archive, andtests/encoding.spec.jsnow fails the build on mojibake, a BOM, invalid UTF-8, a version that disagrees across the three release files, or aCITATION.cffwith no DOI for the version it claims to be.
Focus: making a real figure. Every item came from assembling a spot-dilution plate figure and hitting where the tool got in the way.
sv()/gv() control helpers wrote to and read from .value on checkboxes, where .value is the literal string "on" in both states — so a write was a silent no-op and a read was always truthy. No shipped call site used them that way, so nothing was broken for users; it was a trap for the next change, and it had already caught a test. Both now route to .checked.Focus: findability. Prompted by failing to find the crop tool while using the app for real work.
A UI audit found cropping had 12 entry points across 5 surfaces. The two flagship ones — ✂ Batch Crop Images and ⊞ Multi-Crop → Panels — sat under a sub-heading called Scale Tools inside an accordion called Style, wrapped in the Advanced-mode class. Since the app booted in Simple mode, a default user could not see them at all, and the command palette returned 1 result out of 12 for "crop".
setTool() tools, and the only ones that worked in freeform) and Check (compliance, deep audit, duplicate check, exposure, compare) were both buried inside Export behind the Advanced gate. Histogram normalisation moved to Images, next to the panels it acts on.✂ Crop a panel… entry point, and Match Physical Scale/Match Pixel Size renamed to say that they crop — previously only their tooltips admitted it. All ten crop actions are in the command palette.Plate 3 T7), so an 18-panel figure from 6 plates needs no decoding afterwards. Naming is optional and a named panel is never overwritten by the auto-lettering.renderFreeform already drew them and they were already in exports — but all three canvas handlers delegated the event to the object code before the annotation logic could see it, so arrows, boxes and text were unreachable. They now draw, select and drag in both modes. Panel-pin mode stays grid-only, since there is no grid cell to pin to.Focus: PDF exports carry real text, not a picture of text.
α-tubulin), ≥, ✓, CJK — is left in the raster rather than silently substituted, since a wrong glyph in a scientific label is worse than a slightly softer one. µm encodes correctly and stays live text. The raster render is told, per string, exactly what the text layer will draw, so nothing is ever drawn twice or lost.tests/validate_pdf.py — an independent check with pypdf confirming the exported files really parse, that the text extracts, and that each string lands where the figure put it.Focus: hardening. A whole-app audit hunted for places a number could be wrong while looking authoritative.
Integrity fixes, each with a regression test confirmed to fail against the previous build:
Freeform scale bars ignored the crop — magnification was computed against the full image width instead of the cropped width, so a panel cropped 50% drew its "20 µm" bar at the length of 10 µm. A reader measuring against it would have overestimated every structure by 2×. Grid mode was already correct; both now share one helper, and Auto bar length is fixed the same way.
Channel merges ignored the crop — extra channels were drawn from their full frame into the cropped panel rect, displacing and squashing them by up to half a field, which can show colocalisation that is not in the data. Each channel is now cropped to the same normalised field of view, so channels acquired at different pixel sizes still register.
Raw measurements could read pixels outside the figure — an ROI over a contain-fit panel's letterbox, or spilling past a cropped edge, reported a confident raw value computed from pixels the user had cropped away. Measurements are now clipped to the visible crop window, disclose when they were clipped, and fall back to the disclosed display reading when nothing visible remains. A line profile leaving the image declines rather than reading clamped edge pixels.
Exposure analysis scanned the whole sensor frame, so a saturated region cropped out of the figure still failed the panel. It now measures the cropped panel, and reads the drawn image rather than the whole grid cell.
µm² is withheld, not guessed, when a panel cannot be mapped back to source pixels (rotated, flipped, shape-cropped, or a channel merge) — it was previously wrong by the display scale.
GenBank features crossing the origin collapsed to their full-span envelope, drawing a full-ring arrow that hid every other feature — and most real plasmids have an AmpR or ori crossing position 1. Wrapping features on circular records now become their two real arcs; ordinary multi-exon joins still collapse to their span, now disclosed.
Duplicating a gene map or chart bricked the canvas — the deep copy turned a cached canvas into {} and every later render threw.
178 Playwright tests (up from 157).
Focus: measure the real data, and draw the constructs behind it. Same single, offline, private file.
⤢ Rescale… multiplies every coordinate so a design keeps its proportions at a new total length. Import reads only what the file declares (LOCUS length, topology, FEATURES table, complement() strands) — nothing is inferred and no feature is invented.<polyline>/<rect> glyphs and live <text> labels, and raster exports redraw at the target DPI.Focus: transparency, honesty, and polish — driven by a deep review of what scientists actually need. Same single, offline, private file.
Focus: the data figures, flowcharts and asset-rich schematics that used to need a second tool — all in the same single, offline, private file. Every chart draws as true vector.
.csv/.tsv or paste from Excel and get a bar, grouped/stacked bar, line, scatter (with an OLS linear fit), or box-and-whisker plot that composes alongside image panels, rotates and hit-tests like any object, and renders crisply at export DPI. Charts draw through a swappable emit backend (canvas and SVG from one geometry pass), so SVG export carries true <rect>/<line>/<text> — never a rasterised blit. Colourblind-safe palettes (Okabe–Ito default).SEM = SD/sqrt(n), SD with ddof=1) written into a comment, plus a note that significance marks are author-supplied.Ctrl+G / Ctrl+Shift+G) so a box drags its label as one and duplicates rewire correctly. Elements now carry stable string ids for reliable references.Focus: a friendly, direct-manipulation figure editor + opening the image formats scientists actually have — same single file, still offline & private.
.tif/.tiff that browsers can't (Chrome/Edge/Firefox): uncompressed, LZW, PackBits, Deflate & JPEG-compressed; 8/16-bit & 32-bit float; grayscale, RGB, RGBA, CMYK & palette; strips or tiles; little/big-endian; horizontal-differencing predictor; and multi-page TIFFs (each page becomes a panel). Verified pixel-exact against a tifffile-generated fixture matrix.Published & citable — latest version DOI 10.5281/zenodo.22084181 (v3.13.0). The concept DOI always resolves to the latest.
Focus: UI/UX clarity, onboarding, scientific integrity, and export reliability — without changing the single-file, offline, privacy-first identity.
Submission package — a 📦 Export submission package (ZIP) button bundles everything a journal or lab needs into one archive: PNG, TIFF (LZW when Compress TIFF is on), lossless PDF, SVG, session JSON (images embedded), metadata CSV, caption, the Methods paragraph, figure notes, R + Python scripts, reproducibility log, each panel as a separate PNG, and a provenance manifest. Built on a dependency-free client-side ZIP writer.
Editable PowerPoint (.pptx) export — 📊 Export editable PowerPoint writes an OOXML deck where each panel is a separate, movable picture at its real position and the title is an editable text box — finish the figure in PowerPoint. Fully offline, no dependencies.
Smart image import — image cards show pixel dimensions, aspect ratio, a channel chip detected from the filename (DAPI/GFP/mCherry/Cy5/phase/merge), and a duplicate-name warning; Sort by name natural-sorts and relabels, Auto-channels assigns colourblind-safe LUTs from the detected channels.
Split-channel row — one click explodes a multi-channel composite into separate single-channel panels (each with its LUT and a channel caption) plus a merge.
Matched / linked panels — assign panels a Match group and ⇉ Sync group copies brightness/contrast/gamma/LUT/levels/scale-bar length across the whole comparison group, so it's processed identically (integrity-safe).
Gel/blot splice marker — drop a visible lane-divider line where blot lanes were spliced (a journal requirement), under Annotate → Blot/gel tools.
House styles — save your lab's look (background, label font/size/colour/format, scale-bar style, panel border, typography) and apply it to any figure in one click, so every figure in a paper matches. (Distinct from Templates, which save layout.) Under Style → House styles.
On-device AI caption polish (optional) — a 🤖 Polish with on-device AI button refines your figure legend using Chrome's built-in Gemini Nano (Prompt API), running entirely on your machine so nothing is uploaded. Feature-detected; falls back to the rule-based caption when unavailable. See On-device AI.
"What's New" tab in Help with the dated changelog, plus a "new" dot on the header ❔ after an update.
Also in v3.5 (workflow, integrity & onboarding):
condition_time_channel_rep); crop editor gains arrow-key nudge and copy-crop-to-group/all.Make it yours:
prefers-reduced-motion.Ctrl+P hides all UI chrome)If you use FigureLab in your research, please cite it. Metadata lives in
CITATION.cff, so GitHub shows a "Cite this repository"
button automatically.
Awuah, M. B. (2026). FigureLab: a browser-based tool for assembling publication-quality scientific figures (Version 3.17.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.21269456
BibTeX:
@software{awuah_figurelab_2026,
author = {Awuah, Michael Baffour},
title = {{FigureLab: a browser-based tool for assembling publication-quality scientific figures}},
year = {2026},
version = {3.17.0},
publisher = {Zenodo},
doi = {10.5281/zenodo.21269456},
url = {https://github.com/mbaffour/FigureLab}
}
The DOI above is the concept DOI — it always resolves to the latest version.
To cite this exact release, use its own version DOI. Each release's version DOI is
listed in CITATION.cff; the most recent recorded there is
v3.13.0's, 10.5281/zenodo.22084181. Every archived version's DOI is listed in CITATION.cff.
Free and open-source for scientists.
system-uiNew: repeat an arrow, box or text on the other panels. Pointing at the same structure in the DAPI, GFP and merge panels meant drawing the arrow three times by eye, never quite in the same place, which makes it look as though the channels show different cells. Select a 📌 panel annotation and pick ⧉ Repeat on… this row / this match group / every panel from the floating toolbar or the command palette. Where two panels show the same picture (same source size, crop, turn and flip), the copy lands on the same pixels, even when rows hug their panels and the cells differ in height. Where they don't, the copy keeps the same place in the panel, and the toast says so. One undo takes it all back, and pressing it twice does not stack copies. Deleting a panel annotation is now undoable too; it never was.
A share link keeps per-gutter spacing. The link carried the per-gutter overrides but never read them back. The recipient got uniform gutters and a figure narrower than the one shared. Opening the link now restores each gutter and the per-gutter spacing switch, exactly as loading a saved session does.
The provenance hash knows when rows hug their panels. The SHA-256 and the PNG's FigureLab-Settings recorded the panel height but not Rows hug their panels. Two figures with different row heights therefore carried the same hash, which is exactly what the hash exists to tell apart. Both now record the switch and the height of every row as drawn, and the hash covers per-gutter spacing too.
Undo no longer brings back a loaded figure's extra headings. Loading a figure with four column headings over one with two, then undoing, left the third and fourth headings in the store. They reappeared as soon as the grid grew back and were saved with the figure. Undo now restores the heading list exactly as it was.
A second pass over this release's own fixes. A six-way adversarial review of the work above, with every finding checked three ways, confirmed thirteen defects in it, and each one is now fixed:
The shipped package describes the panels the figure actually draws. The caption, the metadata CSV, the journal-compliance check and the package manifest all enumerated images.slice(0, cols × rows) — raw array order. A hidden panel still occupied one of those places, so the ZIP described panels the reader cannot see and silently dropped a visible one off the end: a ten-panel figure shipped a caption for twelve, a CSV whose rows ran 1–6 then 8–12, and a compliance line reading "11/11 panels labelled". All four now use the panels in reading order that the figure draws. PANEL_HISTORY.txt still records a hidden panel — you did process it — but marks it [hidden from the figure].
Every plane of an OME-TIFF is calibrated, not just the first. OME-TIFF and ImageJ write their description into page 0 only, and the calibration reader looked at the current page's. Frames 2…N of a real Bio-Formats or tifffile stack — which carry PhysicalSizeX and no resolution tags — therefore arrived uncalibrated with their scale bar switched on and nothing to draw it from. The description now falls back to page 0's, exactly as the acquisition-time reader already did. Per-plane DateTime is untouched and stays per plane.
In panel mode the channel key sticks to its panel. 📌 Panel mode binds every annotation to the panel under the cursor — except the ▤ Key, which went on writing figure-level coordinates. Placing it looked identical; changing the H gap then left the key stranded on the background while its panel moved away. The Key tool's own tooltip promises it is "kept in step with the panels as they change".
New: rows hug their panels. Every cell was the same height, so a wide short panel — a blot, a gel, a kymograph — floated in a tall cell with dead space above and below it, and closing the gaps did not help because the space was inside the cell. Stacking a blot over its loading control left a gulf between them that no setting could remove: ⤢ Fit panels to images can only pick one shape for the whole grid, and says so. Tick Rows hug their panels (or ⌘K → Rows hug their panels) and each row shrinks to the tallest panel in it. Rows never grow past the panel height you set, panel width does not move, and the headings, group bands, gutter handles and every export follow the new heights.
Lane numbers, MW ladders and splice markers land on the blot. All three store their position as a fraction of the whole figure. In a one-panel figure that is the same thing, which is why it went unnoticed — but in a 2 × 3 figure ↕ MW ladder laid its weights down the left edge of the entire sheet, nowhere near the blot, and ⎸ Add splice marker drew a line straight down the middle of the figure. They now measure from the selected panel (or the only one), place lane numbers above it and weights beside it when the margin has room, and name the panel they landed on.
Typed row and column headings stop disappearing. The headings lived only in the text fields that showed them, and those fields are destroyed and rebuilt whenever the grid resizes or the "Column labels" box is ticked. Adding a column, hiding the headings, undoing an unrelated change, or saving a figure with the headings hidden all threw the text away — and the rebuilt field read Col 3, which looks like a heading you wrote and renders into the export as one. They now live in the figure's state, so they survive all of that and round-trip through saves, share links and undo.
The on-canvas heading toggle says what it does. An 18 px chip in the top-left corner wore a ×, had no tooltip and no hover state; one click on it hid every row and column heading. It now carries the same ⊞ mark as the sidebar's ⊞ Labels button, lights up under the pointer, and reads "hide the row & column headings".
Greyscale, Invert and Reset are undoable. All three are buttons, and the capture-before-edit handler that makes the sliders undoable only watches inputs and selects — so a click on Reset, which clears brightness, contrast, gamma, LUT and both level points at once, could not be taken back. Crop shape joins them.
One press of undo undoes a typed panel tilt. The Tilt field recorded two undo steps — one before the edit from the generic handler, one after it from its own — so the first press restored the tilt you had just typed and appeared to do nothing.
Group bands are in the undo history. They were left out of the state snapshot entirely, so the undo step that setFigGroups and the band ✕ button already recorded restored everything except the bands. Re-running a band generator also blanked the names you had typed; names now carry across when the band still covers the same rows.
Panels arrive in the order you picked the files. Every import path is asynchronous, and they were all fired at once, so panels were committed in decode-completion order — a small PNG overtook a large TIFF, and a twelve-file drop landed shuffled. Panel letters are handed out at commit time, so B could end up sitting left of A. Files now open one at a time.
Relabelling with a panel hidden no longer makes two panels called B. Hidden panels kept the letter they had while the visible run re-used it; they are now lettered after the visible ones, so the reader still sees A, B, C with no gaps and nothing is lettered twice.
Fixed (blocker): after straightening a panel you could never crop it. A panel opens the crop editor with the whole frame selected but no box drawn; one press of 📐 Level, ✨ Auto, ↺ ↻ or the Tilt field counted that full frame as a box, so every later drag was treated as moving it — clamped to no movement — instead of drawing one. Straightening a specimen and then cropping it is the entire point of the tilt, and it could not be done.
Fixed (blocker): an annotation endpoint could not be dragged under its own toolbar. The floating toolbar sits 54 px above the topmost endpoint and over the canvas, so dragging an endpoint upward moved it beneath the toolbar, which then swallowed the pointer and froze the drag — with no keyboard route to finish it, since grid annotations have no arrow-key nudge. The toolbar now steps out of the pointer's way for the duration of a drag.
Fixed: the crop editor showed the previous panel's tilt. Opening it on a panel that had never been cropped displayed the last panel's angle, pixel box, and the amber "resampled — panel pixels are interpolated" warning, while the canvas simultaneously said "Draw a region to continue". An interpolation warning that isn't true is the wrong kind of wrong on a figure-integrity tool.
Splitting a merge now produces the row it promises. splitChannelRow is named for a row, and its button offers "a row of separate single-channel panels plus a merge" — but it never touched the grid, so a 2×2 figure stayed 2×2, and its toast recommended Auto-arrange, which lays 3 panels out as 2×2 and so could not produce that row either. When the split parts are the whole figure it now sets one row; when other panels are present it leaves their layout alone and says so instead of giving advice that does not work.
The merge control is reachable. Both halves of making a two-colour merge sit at the bottom of a 1400 px per-panel drawer, past fifty other controls. ⌘K → Merge channels now opens that panel's drawer and scrolls + Add channel into view, and that button — the only one in the drawer with no tooltip — explains what it does.
Tighten spacing, a journal preset and a loaded template all wrote the uniform H/V gap, reported success, and changed nothing for that gutter. Setting one gap for the whole figure now clears the per-gutter overrides and says how many went, so the figure always matches what the fields show.colGaps/rowGaps/advancedSpacing, so a template lost it at save time and a recipient of a share link got a different figure with no warning. Loading a template also left the previous figure's overrides in place, which made the template's own gaps inert. Both now carry it, and loading a template replaces what was there rather than layering on it.max(6, gap/2+6) wide, so reaching for a chip left the band and wiped the chips before the click landed. Below a gap of roughly 41 px — including the default 6 and the 2 and 4 the journal presets set — both chips were permanently dead. They now stay alive while the pointer is on them.Icon browser shows 12 per group with "Show all N" instead of rendering every icon at startup (Annotate & Draw: 233 buttons → 108). Search still returns everything that matches. AI suggestion chips fold away under the prompt.
Accessibility: .field labels are associated with their controls at startup (for derived, not hand-edited) — 40 inputs a screen reader announced as unlabelled now have names, and clicking the label focuses the control. Muted helper text has a 10 px floor.
Fixed: the footer carried its own hard-coded version string (v3.9.0) and had drifted behind the header badge; both now stamp from the one constant.
⏱ Tag from acquisition time. A time-lapse dropped in as a multi-page TIFF becomes a row of panels; this reads the time the microscope recorded for each — OME-TIFF Plane DeltaT (with its unit), an ImageJ frame interval, or the baseline TIFF DateTime tag — and tags the panels with the elapsed time from the earliest of them, in whichever of s / min / h keeps the numbers small. Panels whose file carried no time are left alone, and a figure mixing relative and absolute times is refused rather than given a made-up zero. The time as read also goes into the metadata CSV.
Tags and intensity-bar numbers can be dragged, like every other label, and double-clicked to reset. They were already in the draggable-text layer, so a drag started and the cursor changed — but there was nowhere to store the offset, so nothing moved.
Group band labels can be dragged, the third label with the same dead-drag bug; the offset is saved with the session.
A tag no longer lands on top of the panel letter. The letter and the tag are placed by two independent settings; sent to the same corner, the tag now steps below the letter.
{v}, {u}, {n}). With Channel names on, an empty tag names the panel's channels each in its LUT colour — DAPI in blue, GFP in green — the way QuickFigures and OMERO.figure do from metadata; here from the LUT, the extra channels and the filename. Vector text in SVG and PDF.README.txtimages[] holding one page's slice — the user's other panels gone, with no undo entry — now restored in a finally.logical/96 inches. The viewBox is unchanged, so the vector layer is byte-for-byte identical.bi_line icon embedded @font-face{src:url(https://excalidraw.com/Virgil.woff2)}, so placing it reached a third party. Both are gone, and tools/build-icons.mjs strips remote @font-face on import so the next tranche cannot reintroduce one. "No internet required" is now literally true, and a test fails the build if it stops being.:focus rule in the file at all.pHYs, TIFF XResolution, every PDF /MediaBox, each panel PNG inside the ZIP — because the printed-width bug survived a 359-test suite that only ever asserted the app's own readout. One pre-existing red test is fixed: it cached a canvas rect and missed its hit target by 0.35 logical px.PANEL_HISTORY.txt + .json): every operation applied to every panel, in order, with tilted crops flagged as resampled and non-destructive adjustments labelled as such. C2PA-shaped in intent and explicitly unsigned — it is the author's account, not proof.{condition} {time} resolved from the fields the filename parser already found, with unknown tokens leaving a gap rather than printing braces into a published figure. Duplicate row / column.stubGemini route helper so no test ever calls the paid API.APP_VERSIONtests/ui-ia.spec.js includes an orphaned-id scan that extracts every getElementById from the source and checks it against the live DOM — the failure a large re-parenting causes that behavioural tests miss.Ctrl+D), lock, and multi-object layer ordering for Freeform objects.Task-based sidebar — sections named for what you are doing (Images, Arrange, Crop & Scale, Annotate & Draw, Look, Measure, Check, Export), in workflow order. Every tool stays visible; sections collapse and deeper tools group under a "More" rule. See Finding things. (Replaced the Simple/Advanced toggle in v3.9.3.)
Empty-state start screen — a blank canvas now shows a launchpad: drop images, start from a template, load a session, or try the example figure, plus an Import → Layout → Calibrate → Annotate → Audit → Export workflow strip.
Load example figure — one click generates four procedural micrographs (DAPI / GFP / mCherry / merge) in a labelled, scale-barred 2×2 so you can explore instantly with no files. Clear with one undo.
Command palette — Ctrl/⌘+K opens a fuzzy launcher for every action (render, all exports, audit, save/load, presets, match scale, batch crop, toggle mode, help…).
Export preflight — the Export-panel format buttons show format, file name, DPI, final pixel dimensions, physical size (mm + in), estimated file size, vector-vs-raster status, and integrity warnings before you commit. (Toolbar Quick buttons still export instantly.)
Colour-vision-deficiency (CVD) simulation — a 👁 CVD toolbar selector previews the figure as deuteranope / protanope / tritanope viewers see it, to self-check that fluorescence merges stay distinguishable. Display-only — exports are never altered.
Colourblind-safe default LUTs — new multi-channel merges default to magenta/green rather than red/green.
Contextual ? info chips — plain-language, methods-aware explanations on Export DPI, formats, panel labels, scale bars, per-panel adjustments, and LUTs. Keyboard-accessible.
Auto-arrange panels — proposes a balanced grid, even gutters, and labels for the current panel count. Undoable.
Lossless PDF export — alongside the standard JPEG-based PDF, a Flate-compressed lossless PDF for line art and blots; both now export at the true target DPI (previously screen resolution).
In-app Help & FAQ — a tabbed Help modal (Getting Started · Use Cases · FAQ) reachable from the header ❔ and footer.
Docs/accuracy: version unified via a single APP_VERSION; corrected the outdated "images aren't saved" note (sessions embed images — see Session save); added a CITATION.cff / Zenodo metadata for citing the tool; CI runs the Playwright suite (now 33 tests).