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):
- Open
figure_lab.htmlin your browser (Chrome or Firefox recommended) - Drag images onto the drop zone, or click to browse
- Adjust the grid layout, labels, and scale bars
- Click ⟳ Render (or press R) to preview
- Export in your chosen format
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:
- On-device caption polish — runs entirely in your browser via Chrome's built-in Prompt API (Gemini Nano). No server, no key, nothing uploaded, works offline.
- Generate with your Gemini account (the default generator) — FigureLab itself sends nothing. It copies your prompt to the clipboard and opens gemini.google.com, where you generate on your own signed-in account (free on consumer accounts), then paste the image back. The result is tagged AI-generated and disclosed in every export. There is no in-app "sign in with Google": Google's image API has no OAuth path a serverless static file can use, and proxying a consumer Gemini session violates Google's ToS — so the bridge is the honest maximum.
- Direct API mode (advanced) — you paste your own AI Studio API key, and clicking Generate sends your prompt (and, in later versions, any reference schematic you explicitly attach) to Google's Gemini API. The key is stored only on your machine (or held for the session only, if you prefer). Creating a key is free; image generation requires billing linked to the Google account (Google removed the image-model free tier in 2026, ~$0.03–0.24/image). All Gemini output carries Google's invisible SynthID watermark.
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.
- Click 🤖 Polish with on-device AI under the caption helper.
- It's feature-detected: if your browser doesn't expose the built-in model (
window.LanguageModel), FigureLab silently keeps the built-in rule-based caption — the feature never blocks anything or requires a network. - An echo/guard check rejects degenerate responses, so a good caption is never clobbered.
- Requires a recent Chrome (138+) with built-in AI enabled; the ~4 GB model downloads once, then works fully offline.
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:
- Arrow — single-headed; points to features
- Arrow2 — double-headed; spans a distance
- Ruler — line with tick marks at each end; labels µm length if calibrated
- Rectangle — highlight regions; optional filled with adjustable opacity
- Ellipse — outline structures; optional filled with adjustable opacity
- Line — measure or indicate
- Text — free-form labels; font, size, and colour controlled in the float toolbar
- Inset — magnify a region; automatically placed in a panel corner
- Counter — click to place numbered dots; running count shown while tool is active
Editing annotations:
- Switch to ↖ Select (or press Esc) to enter select mode
- Click any annotation to select it — a golden highlight appears
- Drag to reposition; drag corner handles to resize
- A floating toolbar sets colour, line width, font size, fill, and opacity
- Delete from the toolbar or the annotation list in the sidebar
Drawing tips:
- Hold Shift while drawing to snap lines/arrows to 45° increments, or to force squares/circles
- Panel annotations (📌 mode) pin annotations to a specific panel — they follow it when panels are reordered
Undo / Redo — full history for all annotation actions
- 🧬 Duplicate-panel check — compares the pixels of every pair of panels, including rotated and mirrored reuse, and flags near-identical ones before a journal's screen does
- Deep figure audit — LUT/brightness/gamma/intensity consistency, scale-bar calibration, label coverage, and the duplicate check
- Journal compliance — printed width and height in mm, true text point size, DPI, background, and export size
- ROI — area, mean/min/max intensity, SD (n−1), and integrated density over a dragged rectangle
- Line profile — an intensity trace along a dragged line
- Cell count — connected-component count above an intensity threshold
Measurements read the native samples of 16-bit/float TIFFs where an exact mapping exists, and the 8-bit display otherwise — always stating which.
- Exposure analysis — per-panel over/under-exposure, dynamic range, and saturation
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.
- Linear constructs and circular plasmid maps
- SBOL Visual glyphs — promoter (bent arrow), RBS (half-dome), CDS (arrow-block), terminator (T-bar), operator, origin of replication, primer, restriction site, ribozyme, insulator, scar, ncRNA, protein tag; an unrecognised type falls back to a labelled box rather than failing
- Strand direction shown by the glyph itself — arrow-blocks on linear maps, tapered arcs on plasmid maps — with forward features above the backbone and reverse below
- bp ruler and a size stamp; labels stagger across rows instead of overprinting on dense constructs
- Three ways in — paste a feature table (
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 - Scale the whole design — set the backbone length in bp and the drawing rescales, or use
⤢ Rescale…to multiply every coordinate so a construct keeps its proportions at a new size - True vector export — real
<polyline>/<rect>glyphs and live<text>in SVG, redrawn at target DPI for raster
GenBank 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 |
- Space two panels apart — hover the seam between them; a blue divider with a
gappill appears. Drag it (or tap +) to push them apart. Drag a horizontal seam for row spacing, a vertical seam for column spacing. - Give one row extra breathing room — tick Per-gutter spacing in the Layout panel, then drag only the gutter under that row. Everything else stays tight. Reset gutters restores a uniform grid.
- Drop labels in one click — click ⊞ Labels in the canvas toolbar (or the small × chip in the figure's top-left) to add or remove row & column headers instantly.
- Write directly on an image — double-click a panel, type your text, press Enter. The label is pinned to that panel (it follows the panel if you reorder) and gets a dark halo so it stays readable over bright micrographs. Toggle the halo from the floating toolbar.
- Export print-ready — set DPI to 300–600 and export PNG/JPEG/WebP: the figure is re-rendered at the true pixel count (supersampled), so text, scale bars, and lines stay crisp — not just DPI-tagged.
- Drag the ⠿ handle in the image list to reorder panels (locked panels cannot be moved)
- Apply to all copies brightness, contrast, gamma, LUT, and B/W points from one panel to all others
- Duplicate a panel (⎘ button) to reuse its settings for similar images
- Use the mini grid map in the Layout section to see panel assignments at a glance
- Annotations are baked into the exported figure — finalise the layout before annotating
- For journal submission, use TIFF at 300–600 DPI with a white background
- The uncalibrated warning badge (⚠) appears on scale bars when µm/px is not set
- Everything runs in the browser — no data ever leaves your computer, and the app makes zero network requests: no fonts, scripts, stylesheets or icons are fetched from anywhere. A test in the suite fails the build if that ever stops being true.
- Printed width is the anchor. Choosing a journal column width (89 mm, 183 mm, …) fixes the physical size; the DPI you pick then fixes the pixel count. Every format below writes that physical size into the file, so a pre-flight check reads the size you asked for. With no column width set, one logical pixel is 1/96", as before.
- Raster export (PNG/JPEG/WebP): the figure is re-rendered off-screen at the true target-DPI pixel count (supersampled, high-quality smoothing), so output is genuinely high-resolution — capped at ~60 megapixels to stay within browser memory. If the cap reduces the resolution, the file is tagged with the DPI it actually has, not the one you asked for.
- TIFF export: RGB, uncompressed or lossless LZW with horizontal differencing (Compress TIFF, switched on by the PLOS preset), scaled to target DPI (not just metadata — actual pixel count is correct), with XResolution/YResolution set to the resolution the pixels really carry
- PNG export: a spec-correct
pHYschunk plussRGB/gAMAand reproducibility metadata — the DPI reads correctly in Photoshop / ImageJ - PDF export: two modes — JPEG-compressed raster (
/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/MediaBoxis 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. - SVG export: background PNG at the target scale + native SVG shapes and real
<text>for every label;width/heightcarry the printed width while theviewBoxstays in logical units - PDF text is not embedded — labels use the base-14 faces (Helvetica/Times/Courier) with WinAnsi encoding, and anything those cannot represent (Greek, ≥, ✓, CJK) is deliberately left in the raster rather than silently substituted. One known consequence: glyph advances are measured with the browser's own font, so a centred label can sit up to ~1 pt off centre. Left as-is: nothing is lost or mangled, and embedding a font would end the "no bundled binaries" property of the single file.
- Gamma correction uses a 256-entry LUT computed once per render for speed
- Fonts: the interface prefers JetBrains Mono and Instrument Serif if you already have them installed and falls back to a system stack otherwise — nothing is downloaded. Figure labels have always used their own font control (default
system-ui), so figure output is unaffected either way.
- New: 🎓 Tutorials. Five step-by-step tutorials, each of which builds its own small demo figure and walks you through the real controls: a western blot with its loading control (greyscale, rows that hug, lane numbers, MW ladder, splice marker, compliance, Methods), a multichannel fluorescence row (merge ticked C1/C2/C3 files, split into a row, channel key), straighten a tilted specimen, then crop, headings, group bands and spacing (a phage spot-plate grid across two host strains), and get a figure through submission (journal preset, per-panel ppi, compliance, Methods, LZW TIFF, package). When a step asks you to do something, the card waits and moves on once you have done it. Do it for me does the step for you, so you can't get stuck, and Esc ends a tutorial at any point. Starting one over your own figure asks first and is a single undo step. They live in a new 🎓 Tutorials tab in Help, on the welcome screen, and in ⌘K (tutorial western, tutorial channels…). The five-step Quick tour is unchanged. A test walks every tutorial end to end, so a renamed control fails the build instead of stranding someone halfway through.
- Batch crop: the size is shared but no longer frozen. Resize the box with its handles or the W/H fields on any image and that size carries to every image still to come. A note says when images already cropped are at a different size, and ⤢ Size → done brings them into line, each about its own centre and keeping its tilt. A click outside the box still moves it rather than starting a new one, so a stray click cannot lose the size. Trim borders stays off in batch, since trimming one image to its content would change the size for all of them.
- Batch crop: edit each image without leaving the batch. Brightness, contrast, gamma, LUT, grayscale, invert, 90° turns and flips sit under the crop box. The editor canvas previews the levels and LUT through the renderer's own code, and a small panel preview shows the crop as it will come out, turned and flipped. Carry edits to next images (on by default) starts each image with the same settings so a series is processed identically; ⇉ Edits → all applies them to every image in the batch at once, and ✓ All carries them to the remaining images. Images whose edits you never touch keep their own adjustments.
- Batch crop: ← Back reopens the previous image with its saved crop and edits to re-edit it.
- Arrow keys in the crop editor no longer move the box while a slider or number field has focus.
Built 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. + Add channel now opens TIFFs too, says so when it cannot open a file, and can be undone.
-
New: 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:
- Rows hug their panels is saved with the figure: sessions, autosave, share links and templates carry it. A figure saved before the option existed reloads with uniform rows, because that is how it was drawn.
- Typed headings stay with their own figure. Loading a session or starting a new figure used to let the previous figure's headings reappear as soon as the grid grew.
- A hugged row no longer snaps back to full height because it has an empty cell, so an odd number of blots still stacks tight. Row heights round up, so a width-limited blot keeps its full width and its lanes stay in register.
- The status bar, the deep figure audit, the compliance size row and the generated R/Python scripts report the row heights actually drawn, not the panel-height field. A hugged row under 80 px is a warning, not a failure: it is short because its panel is.
- Panel-mode annotations get what figure-level ones already had: the toolbar steps out of the way mid-drag, a release off the canvas ends the drag, and a pinned channel key can be grabbed anywhere on it and exports to SVG as text.
- The caption, CSV, compliance and manifest skip a panel pushed out of the grid by a spanning neighbour. Hidden panels no longer fail the calibration, adjustment or inset lines.
- An SVG that fails to rasterise is reported and no longer stalls the rest of the drop. The freeform crop editor can draw a box after a straighten, the half of the straighten-then-crop fix that only covered the grid. Lane numbers sit above the blot instead of across its top edge, and a click that moved nothing records no undo step.
-
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.txtstill 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
tifffilestack — which carryPhysicalSizeXand 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-planeDateTimeis 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
setFigGroupsand 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
Bcould end up sitting left ofA. 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.
splitChannelRowis 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.
Three things people went looking for and could not find, and several places the interface said something that was not true.
- Rotate a photo from the canvas. Click a panel and a round grip appears above it: drag to turn the picture to any angle (Shift snaps to 15°), or nudge with [ and ], or type an exact angle beside the quarter-turn buttons. Rotation had existed only as three quarter-turn buttons inside a panel's settings drawer, which is not where anyone looks for it.
- Fixed: the Save dialog reported the wrong size with a column width set. It computed its own physical size from
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. - Fixed: the compliance report named the wrong journal. It said "Checked against Nature/Science/Cell" even with the PLOS, eLife, EMBO or PNAS preset applied, and never named the one you chose. It now says which preset is applied and that you should check its own spec.
- The ⚠ beside an uncalibrated panel is now a button. Every panel ships with its scale bar switched on, so with no µm/px set the bar cannot be drawn and the only cue was a 5 px inert ⚠. It now reads ⚠ calibrate and opens the calibration dialog.
- Two misleading headings renamed. "Label style" sits 335 px above the real panel-letter controls and styles row and column headers instead, so hunting for A/B/C found a block that does nothing — it is now "Row / column header style". "Scale Bar (global defaults)" could neither add a bar nor set its length; it is now "Scale bar appearance (all panels)" and its help says where length and calibration live.
- Fixed: the "Rotate:" caption could wrap away from its own buttons, leaving three unlabelled glyphs opening a row 955 px away at common laptop widths. The group is now one unit, with accessible names on the buttons.
- The command palette now answers what you type. It matched the whole query as one literal substring and then fell back to a letters-in-order match that hit almost anything — and rendered those guesses identically to real hits, so a miss looked like an answer. Worst case: typing undo and pressing Enter ran Reset all crops, because that entry's keywords contain the word. Now: every word of a query must land somewhere, a word-boundary hit outranks one buried inside a longer word, and a guess is shown under "Nothing matches … — did you mean:" instead of masquerading as a hit. 24 commands were added for things the palette simply could not reach — Undo, Redo, the panel gaps, panel size, export DPI, printed width, the arrow and shape tools, calibrating a scale bar, panel letters, merging and splitting channels, opening a saved session, and the journal check.
- A palette entry can take you to a control, not just run one. Where the answer is a setting rather than an action, the entry opens the section it lives in, scrolls to it, focuses it and flashes it — so "where do I set the gap between panels?" has an answer even though that field sits inside a section you would have to know to open.
- Fixed: a per-gutter override silently outranked every uniform gap. With Per-gutter spacing on, a gutter you had dragged kept its own value, and
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. - Fixed: layout templates and share links dropped per-gutter spacing. Neither saved
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. - Fixed: a journal preset could not be undone. It rewrites panel size, gaps, fonts and export width; with no undo entry, trying a house style to see what it looked like was a one-way door.
- Fixed: page 2 of a multi-page PDF drew page 1's row gutters. Row overrides were looked up by the row's position on the page rather than in the whole figure, so every page after the first silently mirrored page 1's spacing.
- Fixed: a gutter drag that ended off the canvas never ended. Release the mouse anywhere but the canvas — the surrounding page, browser chrome, another window — and the divider stayed "being dragged", so every later pointer move over the canvas rewrote the spacing with no button held. The gap ran away and only a click stopped it. A release anywhere now ends the drag, and a move with no button down ends one whose release we never saw. The same stranding affected the rotation grip, a label drag and a scale-bar resize; all four are fixed together.
- Fixed: the − and + gutter chips were unclickable at any normal gap. They are drawn about 25 px either side of the divider, but the divider's hover band is only
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. - Set the space between panels in the freeform editor. There was no way to do it: Distribute spreads three or more objects between the outermost two, so with two selected it returned in silence, and at no count could you ask for an exact gap. The align bar gains Space: [N] px with ⇿ and ⇕, which puts exactly that many pixels between the selected objects on either axis, from two objects upward. Distribute now says why it cannot help instead of looking broken.
- Rotate buttons in the canvas toolbar, beside ⊞ Labels: ↺ ↻ turn the selected photo, or every photo when nothing is selected, and ⌐ sets them upright again. A folder of microscope captures that all arrived on their side is one click, not one click per panel. Also in ⌘K as Rotate EVERY photo.
- Fixed: a turned panel was drawn at the wrong size. The contain-fit was computed from the unturned extent, so a 2:1 image turned 90° into a matching 1:2 cell came out at half the size in a cell it exactly fits, letterboxed — and the panel letter, tag and scale bar were placed against a box the picture no longer occupied, the letter landing beside it on the background. The fit now uses the turned extent at any angle, and the furniture follows the picture.
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…". -
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:
.fieldlabels are associated with their controls at startup (forderived, 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 TIFFDateTimetag — 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.
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.
- Multi-page PDF is the same writer as the single-page PDF. Lossless (Flate) by default, with a checkbox for JPEG; every page carries its labels as selectable text. Page 2 now shows rows 3–4's labels rather than rows 1–2's again, and exporting no longer discards the row labels past the first page.
- The label-format dropdown works. A B C / a b c / 1 2 3 / i ii iii had matched nothing in the code since it was added. It is a display style for auto letters — on the figure, in the PDF/SVG text layer and in the generated caption — and never touches a typed name.
- Batch crop keeps the tilt. It drew the grip and saved an upright crop; now one size for every image, a tilt per image, each starting upright. The freeform crop editor reopens a tilted crop at its tilt instead of at 0°.
- Two more ways to straighten a crop. 📐 Level — click two points along any edge that should be straight, horizontal or vertical. ✨ Auto — a projection-profile search for the tilt at which the straight features inside the box line up, which declines rather than guess when nothing stands out.
- Ratio lock and exact pixels in the crop editor. 1:1, 4:3, 3:2, 16:9 or the panel's own cell, enforced while drawing and resizing (ratios are of pixels, so 1:1 is square whatever the image's shape). X / Y / W / H fields in source pixels, live and writable.
- Crops are stored to 0.01 %. Whole percents put every crop on a 1 %-of-the-image grid — 40 px on a 4000 px micrograph — which quantised every drag and would have made the pixel fields a fiction after Apply.
- ▣ Trim borders, in the editor and for all panels at once: strips the single-colour margin a saved plot or a scan arrives with, rounding outward so content is never cut, and leaves alone any image whose corners disagree.
- Reset all crops now also clears the tilt, and can be undone.
- A loupe in the crop editor. The editor is a thumbnail of an image that may be 4000 px wide, so an edge placed "on" a cell wall was within a few source pixels of it. While a box is drawn, moved, resized, tilted or levelled — or the pointer rests on a handle — a loupe in the corner farthest from the pointer shows the source at no less than 1:1, unsmoothed, with the box edges through it. The modal had promised "corners show at full resolution" for some time; now they do.
- ⊞ Fit grid to panels (Arrange, and ⌘K): rows × columns for the visible panels with the fewest empty cells, landscape and compact — 6 → 3 × 2, 8 → 4 × 2, 5 → 3 × 2 rather than a strip.
- ⛶ Crop to fill cells (Crop & Scale, and ⌘K): crops every panel to its cell's shape, symmetrically about the current crop, so the letterbox bars go. The converse of Fit panels to images, which reshapes the cells instead; both are one undo step.
- Linked insets: draw the region, see it on the parent. Add linked inset… opens the crop editor over what the parent actually shows, so the box you draw is the inset (lock the ratio to this panel's cell to fill it); the middle-third default stays in ⌘K. Each inset's region is outlined on its parent with the inset's letter, turning and flipping with the picture — the convention readers expect — with a toggle to hide the outlines. Drag an outline on the parent to move the inset's region; it keeps its size, stays inside the parent, and is one undo step. The generated caption now says what an inset is — "Magnified view of the boxed region in (A), 2.0× relative to (A)" — and that it is displayed with its parent's settings rather than repeating them.
- Panel tags (Look → Panel tags): a short text on each panel besides its letter — a condition, a time point — typed per panel, or generated as a series (0 min, 5 min, 10 min… with a pattern of
{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. - Intensity bar per panel: a vertical LUT bar with the display range at its ends, in native units when the file's raw samples are kept, for pseudocoloured or grayscale panels. RGB photographs get none, since they have no single intensity axis.
- Blank cells. Insert blank cell (Arrange, and ⌘K) leaves a grid position empty — after the selected panel, or at the end — to hold a place or align a ragged row; it shows in the image list as a row you can remove. Empty cells had been possible by accident (a ragged multi-crop, a panel drag) and broke things: the caption and the session save threw on them, and every export drew the on-screen placeholder — a faint dashed box with the cell number — into the figure. All fixed; an empty cell is background in the export and keeps its place in a saved session.
- Max and mean projections (Images → Combine ticked panels, and ⌘K): tick panels of the same pixel size — the pages of a z-stack — and collapse them into one new panel, the brightest value at each pixel or the average, computed on the displayed 8-bit pixels and saying so in its caption note. Calibration and LUT come from the first ticked panel; the sources are kept.
- Auto contrast for panel letters, typed tags and scale bars: per panel, white or black from the brightness of the image beneath, so a white letter never lands on a white histology section and a white bar never vanishes into a bright-field image. Off by default; not applied under a label chip, which already guarantees contrast.
- ≡ Same bar on all calibrated (Scale Bar, and ⌘K): one bar length for every calibrated panel — the selected panel's, or a clean value about a fifth of the median field of view — and the bar switched on. Split channels into a row now also switches channel names on, so the row reads DAPI · GFP · merge.
- Connect outline to inset: two thin lines from the outlined region on the parent to the inset's cell, joining the facing corners, the way a zoom-in is conventionally linked to its overview. Off by default.
- Field of view in the caption and the metadata CSV: for every calibrated panel, the physical size of the region actually shown — "Field of view 30 × 25 µm" (mm above a millimetre) — from the crop and the calibration. A methods section needs it and a reader cannot recover it from a scale bar.
- ▨ Clipped-pixel preview (canvas toolbar, beside the CVD preview): ImageJ's HiLo view for the whole figure — pixels at the display maximum in red, at zero in blue, with the percentages — on every panel's picture, skipping scale bars and label chips. A saturated pixel carries no measurable intensity, and reviewers look for them. Display only; exports never see it.
- ◪ Spotlight annotation: dim everything but a region — within the panel you draw it on in panel mode, or across the whole figure — with an editable veil colour and opacity. The pixels inside are untouched: this is emphasis, not an adjustment. Exports to SVG as one even-odd path plus the outline, editable like the other shapes.
- The caption uses a panel's tag as its description when there is no caption note, so a time series reads "(A) 0 min. (B) 10 min." rather than "Panel A. Panel B."
- Enter applies and Escape closes the crop editor. Escape goes through the same path as ×, so multi-crop still asks before discarding regions.
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.
- The "Printed width" target now reaches the exported file. Selecting a journal column width (say
183 mm — double column) at 300 DPI produced a PNG whosepHYschunk said 210 dpi, a TIFF tagged the same, and a PDF/MediaBox261 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 asscale × 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. - The generated caption no longer prints a magnification it cannot know. It rendered 1/(µm per pixel) as "×", so a 100× oil objective on a 6.5 µm camera pixel was captioned "(15×)". That is sampling density, not magnification, and a wrong magnification in a legend is a correction-grade error. The caption now states the µm/px it genuinely has. Check any legend already pasted into a manuscript.
- The caption discloses every adjustment that was applied. It disclosed levels and nothing else — gamma, brightness, contrast, invert, the LUT and background keying all reached the pixels undeclared, and gamma is precisely the non-linear adjustment Nature, JCB and Cell require in the legend. Each is now named once, and the legend states whether the settings were uniform across panels or differ. The data was always tracked in the CSV and
PANEL_HISTORY.txt; it just never reached the artefact that goes to the journal. - A scale bar that cannot be drawn now fails the compliance check. A bar outside the drawable range (4 px … 75 % of the panel; 60 % in freeform) was simply not painted — no bar, no label, no handle, no warning — while compliance reported "All calibrated ✓" because it only tested whether µm/px was set. A mistyped calibration therefore shipped a figure with no scale bar and a green report. There is now a failing compliance row with the reason, a pre-flight warning, and a ⚠ badge in the panel list. Bar length is also no longer rounded to whole logical pixels, which happened before supersampling and so did not improve at 600 or 1200 dpi (+9.2 % on a 2 µm bar); grid and freeform now agree for the same calibration.
- The submission package is honest about what it contains. Per-panel PNGs were cropped with
dpi/96while 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 ownREADME.txt. PowerPoint export had the same crop bug and is fixed with it. - Multi-page PDF rendered the un-supersampled preview canvas and declared pages ~3.1× too small, ignoring the printed width entirely; it now uses the same export canvas and page size as the single-page path. A throw mid-export could also leave
images[]holding one page's slice — the user's other panels gone, with no undo entry — now restored in afinally. - SVG export honours the printed width instead of always declaring
logical/96inches. TheviewBoxis unchanged, so the vector layer is byte-for-byte identical. - Zero network requests. The app fetched a Google Fonts stylesheet on every open, and the
bi_lineicon embedded@font-face{src:url(https://excalidraw.com/Virgil.woff2)}, so placing it reached a third party. Both are gone, andtools/build-icons.mjsstrips remote@font-faceon 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. - Landing page meets WCAG AA: the gold used for body text was 3.43:1, now 5.30:1. Added a skip link and focus styles — there was no
:focusrule in the file at all. - 378 Playwright tests (up from 359). The nineteen new ones parse the exported bytes — PNG
pHYs, TIFFXResolution, 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.
Focus: the duplication check journals actually run, and the attribution-required icon tranche the Credits machinery was built for.
- 🧬 Region-level duplication check. The whole-panel check caught a panel reused wholesale; what journal screens (Proofig, ImageTwin) actually flag is a region reused — a lane cloned inside a blot, a patch of one micrograph living in another, possibly rotated or mirrored. The scan now anchors patches to content keypoints (Laplacian-energy maxima), so the same pixels produce the same patches wherever they were pasted — a first fixed-grid implementation missed a duplicate shifted by 8 px, which is disqualifying for an integrity tool and why the grid went. Matches merge into regions, shown boxed side-by-side in the report; flat areas are excluded and the report says so rather than passing them. Thresholds are measured, not guessed: a planted rotated duplicate lands at 0.83–0.93 correlation after ±2 px alignment refinement, unrelated texture never exceeded 0.19 across 233 gated pairs. The shifted re-crop that was the documented limitation of the panel check is now a regression test for the region check. Within-panel cloning is covered too.
- 41 more icons: Servier Medical Art and DBCLS (CC-BY). The attribution-required tranche deliberately left out of v3.12.0 until the ⚖ Credits machinery existed to honour it. Placing one creates a real obligation, and the licence records discharge it: Credits groups by author into a paste-ready line pointing at each author's own site (smart.servier.com, togotv.dbcls.jp), and
CREDITS.txtships 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. - Curation remains reproducible:
tools/bioicons-ccby-pack.jsonregenerates the tranche throughtools/build-icons.mjs, same review-then-paste workflow, share-alike still rejected outright. - 359 Playwright tests (up from 351). The region check's development was driven by adversarial fixtures (deterministic noise with planted, shifted, rotated duplications) — two implementation strategies failed against them before keypoint anchoring passed.
Focus: the icon library, doubled with imported artwork — and the licence discipline to make that safe.
- 82 icons imported from Bioicons, taking the library to 169. GHS hazard pictograms, nucleotide bases, fluorescent beads, capsules, plasmids, lab apparatus, molecular-modelling notation, plots and assay readouts.
- CC0 and MIT only. Those are the licences that oblige the user nothing — no attribution line, no publication licence, no watermark, no per-figure fee, which is the entire point next to BioRender's model. Share-alike was excluded (it can be read as reaching the figure the icon is placed in), and software logos were excluded outright: a CC0 SVG grants copyright permission, not trademark permission, and that distinction doesn't survive being placed in a published figure.
- A second icon class: full-colour illustrations. FigureLab's own icons are single-colour line art whose colour you can change. Imported illustrations are artwork whose colours are the content — forcing them through the same recolour would flatten a drawn cell to a silhouette. They carry
mono:false, are placed as drawn, and the colour control hides for them rather than pretending to work. - Two silent-damage bugs found by rendering every icon before shipping it, both in the import cleanup and both invisible in the source text:
- Stripping
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. - Stripping
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.
- Stripping
tools/build-icons.mjscarries both fixes and documents whyidandclassmust survive cleanup.- 351 Playwright tests. The duplicate-key guard now spans the hand-written literal and the generated pack blocks, since an appended pack silently overwriting a built-in is the new failure mode.
Focus: AI that edits schematics and never data, plus the features a competitor survey said were worth having.
- AI editing, with one hard rule. AI-generated and hand-drawn objects gain Edit in Gemini (your own tab, nothing transmitted) and Edit via API, plus variations and style references with explicit consent. Imported experimental images and grid panels are refused — loudly, with the reason: Nature, Science and Cell Press all treat AI alteration of research images as misconduct, and Cell names brightness and contrast explicitly. One predicate (
_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. - Export-time journal AI-policy gate. Exporting a figure containing AI content under the Nature, Science or Cell preset stops and shows that journal's actual position — summarised, dated, and linked, because policies change and FigureLab does not decide this for you. "Disclose & continue" writes the sentence Science asks for (tool, model, version, prompt) into the figure notes, once per session rather than once per export.
- ⚖ Homogenize styles. A figure assembled from several sources drifts: text at four sizes, strokes at three weights, two nearly-identical fonts. Each is invisible alone; together they are what makes a figure look assembled rather than authored. This finds every drifted font, size, stroke width and line colour and collapses each to one value in a single undoable step.
- ⧉ Linked insets. A magnified daughter panel bound to its parent by id: adjust the parent and the inset follows on the next render, reorder the grid and the link survives, delete the parent and the audit says the inset is orphaned. Magnification lands in the metadata CSV rather than being stated from memory. A hand-made inset is a copy that silently diverges the moment the parent is touched — which is how a figure ends up showing two processings of one field.
- 36 new icons and licence-aware artwork. Anatomy and clinical (heart, lungs, brain, liver, kidney, gut, bone, tumour, patient, mouse, blood), study design (timeline, treatment arms, replicates, included/excluded, dataset, quantification), more lab equipment, and the microbiology set this tool is actually used for — phage, plaque assay, colonies, biofilm, plasmid, CRISPR–Cas. Every icon now carries a licence record; ⚖ Credits generates a paste-ready attribution paragraph and writes
CREDITS.txtinto the submission package. FigureLab's own icons need no attribution at all: no publication licence, no watermark, no per-figure fee.tools/build-icons.mjsimports third-party packs under a licence allowlist that rejects share-alike outright. - Per-panel processing history in the submission package (
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. - Deliverable presets (graphical abstract 1328×531 @300 dpi, TOC image 90×50 mm, A0 poster, 16:9 slide), each with its own dated caveat. Token labels:
{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. - 345 Playwright tests (up from 294). Two long-standing tests were rewritten rather than patched: they froze the exact icon-group list and heading count, which taxes the library for growing.
Focus: generating figure artwork with Gemini, using your own account rather than an API key — and making the existing AI path honest.
- 🎨 Generate with your Gemini account. Write a prompt, click the button, and FigureLab copies the prompt and opens gemini.google.com. You generate there — free on a consumer Google account — copy the image, come back and paste. It lands as a schematic object, tagged AI-generated, with the prompt recorded. FigureLab transmits nothing: the clipboard does the travelling, and the app never sees your Google login. A test asserts zero network requests for the whole bridge flow.
- Why there is no in-app "sign in with Google". It was the obvious design and it isn't available: Google's image API publishes no OAuth scopes for generation, web OAuth requires a client secret that a single offline HTML file cannot hold (and
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. - The direct API path works again. It had been calling
gemini-2.0-flash-exp— retired — so it could not have succeeded for some time. Now on the current/v1/…:generateContentendpoint 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 asFailed 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. - Stronger disclosure. Exports now record the exact model id and the prompt behind each AI object, and state that Gemini output carries Google's invisible SynthID watermark. The journal-compliance check gained an AI row. The "photo-realistic microscopy" prompt style is gone: a fabricated micrograph is fabricated data however it is labelled.
- Eight defects fixed in the existing AI extension, one serious: a patch had shadowed
generateAIImagewith 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 skippedpushUndo(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 thefl-settings convention with a one-time migration. - Privacy stated precisely rather than absolutely. Your experimental images never leave your machine under any feature, and AI never touches a data panel — generation only ever inserts a new object. Bridge mode transmits nothing; API mode sends your prompt, and says so at the button. The landing page and README no longer claim "no data leaves your machine" without qualification.
- 294 Playwright tests (up from 270), including a
stubGeminiroute helper so no test ever calls the paid API.
Focus: crops that aren't square to the camera, and multi-crop sessions you can correct without starting over.
- A crop can be tilted. Drag the round grip above the crop box, or type an angle — Shift snaps to 15°, [ and ] nudge by 1°. The region turns and the panel comes out upright, so a plate photographed at an angle no longer forces a choice between a skewed panel and background wedges in the corners. This is different from rotating a panel, which turns the whole cropped picture inside its cell. Works in the single-panel editor, batch crop and multi-crop.
- Tilt is per region in multi-crop. Every region still shares one size, so the panels stay uniform, but each keeps its own angle — six plates photographed at six slightly different angles all straighten independently. Each image starts upright rather than inheriting the last one's tilt, which would be a guess.
- Banked multi-crop regions can be edited. The session used to be forward-only: a region placed slightly wrong three back couldn't be reached, because Undo only removes the last one. Region chips are now controls — click one to reopen it in the editor, move, tilt or rename it, then Update;
×drops it outright. Leaving the name blank on update keeps the name it already had, so repositioning never silently strips a label. - What a tilt costs is stated, not buried. Every other crop in FigureLab selects existing pixels; a tilted one has to resample them, so that panel's displayed pixels are interpolated. The editor says so while you turn it, the metadata CSV gained
CropAngleandResamplingcolumns, the provenance hash covers it, and the deep audit names the tilted panels. - Measurements are not degraded by a tilt. An axis-aligned selection on screen is a rotated quadrilateral in the source, so it is measured as one: a pixel counts if its centre falls inside, and its original stored value is used. Measuring the straightened canvas instead would average neighbouring samples into values that were never recorded and report them at the acquisition bit depth as though they had been. The one exception is crop-on-import, which is destructive — a tilt there drops the native samples and says so.
- The landing page describes the app that exists. It had been stuck at v3.5 since 8 July, still advertising the Simple/Advanced toggle that was removed in v3.9.3 and a crop-on-import step that is now off by default.
- 270 Playwright tests (up from 252). Three of the new ones were checked by mutation — rotating in normalised space, dropping the rotation from the source mapping, and straightening at every angle each make a specific test fail.
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.
- Panels fit their images. A grid cell was a fixed square while a plate photo is 4:3, so every panel was drawn contain-fit with white bars above and below — about a quarter of each cell wasted, multiplied across eighteen panels. ⊞ Fit panels to images reshapes the cell to the content aspect, holding the panel width fixed so the figure's overall width — the number a journal specifies — doesn't move. ⇲ Tighten spacing closes gutters and margins in one action, keeping room only for labels you actually have on. Multi-crop fits automatically on finish, since every region from one pass shares a crop size.
- Row and column headers can sit against the figure. A row header was centred in the left margin, so its distance from the panel was whatever the margin happened to be, and the only way to close the gap was to move the whole figure. A Gap to figure control now means exactly that on both axes: headers are placed relative to the panel edge, and the margin only has to be large enough to hold the text.
- Show a subset of your panels. Cutting 6 plates × 3 regions gives 18 panels for a figure that might show 6 — and deleting the rest throws away the expensive part. Panels can now be hidden: they leave the figure and the grid closes up around them, but they stay in the panel list, in the session file, and in every adjustment you made. Tick panels and 👁 Show only ticked, or use the eye on any row; 🗑 Delete hidden is separate and explicit. Auto-lettering runs over visible panels only, so a subset reads A, B, C, D with no gaps.
- Group bands — the outer level of labelling that a spot-dilution or dose-series figure needs: "No ATC" and "+5 µM ATC" spanning three rows each, with a bracket. Bands are built by explicit row/column range, in repeating blocks of N, or from the panels' own group tags; they run down rows or across columns, and the bracket is selectable per figure — solid bar, square bracket, or none. Bands with no name draw nothing and reserve no space, so building a set and naming them as you go doesn't shift the layout.
- Fixed: the internal
sv()/gv()control helpers wrote to and read from.valueon checkboxes, where.valueis 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. - 236 Playwright tests (up from 220).
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".
- The sidebar is task-based: Images → Arrange → Templates → Crop & Scale → Annotate & Draw → Look → Measure → Check → Export. Measure (ROI/profile/count —
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. - One home for cropping, with a new
✂ Crop a panel…entry point, andMatch Physical Scale/Match Pixel Sizerenamed to say that they crop — previously only their tooltips admitted it. All ten crop actions are in the command palette. - Name regions as you cut them — a per-region name plus a per-image source name, with a running list of what's banked. Region names become panel labels (
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. - Multi-crop works in freeform, where it used to refuse with "needs Grid layout"; each region becomes a placed object. Grid behaviour is unchanged.
- Crop-on-import is opt-in and off by default. It fired on every single-image import and physically cut the bitmap and the retained 16-bit samples that raw measurement depends on. Images now import straight into the figure and are cropped non-destructively afterwards.
- Annotations work in freeform. They were never unimplemented —
renderFreeformalready 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. - Simple/Advanced is retired. One CSS rule hid ~35 controls while the app booted in Simple. Progressive disclosure now happens per section; deeper tools stay visible under a "More" rule. This also removed a trap where choosing Freeform in Simple mode silently reverted to Grid on the next load.
- Mode-aware sidebar — nothing renders that would silently ignore your click. Panel letters hide in freeform, Templates says it builds a grid, and PowerPoint export is disabled with the reason rather than failing after the click.
- One name per concept — "Presets" named four different things across three sections, "Save" five, "Themes" two. Also, the header version badge was hard-coded and had drifted; it is now stamped from
APP_VERSION. - 220 Playwright tests (up from 213). The new
tests/ui-ia.spec.jsincludes an orphaned-id scan that extracts everygetElementByIdfrom the source and checks it against the live DOM — the failure a large re-parenting causes that behavioural tests miss.
Focus: PDF exports carry real text, not a picture of text.
- Live vector text in PDF — the figure title, panel letters, row/column headers, and scale-bar labels are now written as genuine PDF text operators layered over the image, so they are selectable, searchable, and sharp at any zoom. A production editor asking for "text as vector / fonts embedded" is satisfied without a round trip through Illustrator. Previously the entire page was a single raster and labels softened visibly in the proof.
- No bundled font — the text uses the PDF base-14 faces (Helvetica / Times / Courier) that every reader has built in, so no font file is embedded in the PDF and none is added to FigureLab. Bold, italic, serif, and monospace label fonts map to the matching face.
- Never a mangled label — base-14 text encodes WinAnsi (CP1252) only. Any string containing a character outside it — Greek (
α-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.µmencodes 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. - Annotations drawn on the figure (arrows, boxes, free text) remain rasterised; SVG export still carries those as vector.
- 192 Playwright tests (up from 178), plus
tests/validate_pdf.py— an independent check withpypdfconfirming 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.
- 🧬 Duplicate-panel self-check — compares the pixels of every pair of panels, not their settings, and flags near-identical ones including rotated and mirrored reuse (32×32 mean-subtracted luminance, normalised cross-correlation across all 8 dihedral transforms). Journals run duplication screens on submitted figures; this catches an accidental reuse first. Panels too uniform to judge are reported as not-compared rather than silently passed. Whole panels only — it does not detect a duplicated region inside a different panel, and the report says so.
- Printed width in millimetres — journals specify figure width in mm, not pixels. Choose a target column width (Nature 89/183, Science 55/120/183.5, Cell 85/174, PLOS 83/173, PNAS/EMBO 180) and the export lands at exactly that physical size at the chosen DPI. A live readout gives the printed size and the true point size of your panel labels, and compliance fails text below ~5 pt. Journal presets set their own column width.
- Fixed: the on-canvas mm readout and the compliance width check both divided logical pixels by DPI, but exports render at DPI/96 — under-reporting the real printed size by about 3× at 300 DPI.
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
Autobar 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.
- Raw-pixel measurement pipeline — importing a 16-bit or 32-bit float TIFF now retains the decoder's native samples alongside the 8-bit display image (single-channel, capped at 24 MP to bound memory). ROI, line profile, cell count, and exposure analysis measure those, reporting absolute intensity, SD (n−1), and integrated density at the acquisition bit depth — before any brightness/contrast/gamma/LUT. Previous releases could only measure the rendered display.
- True sensor saturation — exposure analysis reports the fraction of pixels at the sensor maximum, measured pre-adjustment, alongside the existing display-clipping figure. Display clipping and acquisition saturation are no longer conflated.
- Correct areas — with an exact source mapping, an ROI's area is counted in source pixels, so µm² finally uses the coordinate space the µm/px calibration is defined in rather than on-screen pixels.
- Honest fallback, never a guess — reading raw values requires 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, a multi-channel composite, or any image with no raw data — FigureLab measures the 8-bit display and says so. Every on-screen result and every CSV row states which pixels produced it.
- 🧬 Gene maps & genetic circuits — a new freeform element draws DNA constructs scaled by base pairs, linear or circular, with SBOL Visual glyphs (promoter, RBS, CDS, terminator, operator, origin of replication, primer, restriction site, ribozyme, insulator, scar, ncRNA, tag). Features carry strand direction — arrow-blocks on linear maps, tapered arcs on plasmid maps — labels stagger across rows instead of overprinting on dense constructs, and a bp ruler shows the scale.
- Build a map any way you like — paste a feature table straight from a spreadsheet (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…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. - Gene maps export as true vector — geometry runs through the same emit backend as charts, so SVG export carries real
<polyline>/<rect>glyphs and live<text>labels, and raster exports redraw at the target DPI. - 157 Playwright tests (up from 133), including raw-value assertions against purpose-built 16-bit TIFF fixtures with hand-computed means.
Focus: transparency, honesty, and polish — driven by a deep review of what scientists actually need. Same single, offline, private file.
- Show every data point — bar and box charts can overlay all raw replicate values as jittered dots (deterministic, so the SVG export matches the preview), exposing the n and spread a bare bar hides — now an explicit Nature/eLife/JCB expectation. Two new distribution kinds join them: violin (Gaussian KDE) and beeswarm (dots with a mean ± SD summary).
- Significance brackets anchored to bar heights — brackets sit just above the bars they compare and stack when nested, instead of floating at a fixed ceiling. Still the author's own typed symbol; no p-value is ever computed.
- Reference / threshold lines — an author-supplied dashed line at a limit of detection, baseline, or cutoff (y or x), drawn as vector.
- Submission-ready packages — each chart's exact rows are written to a Source Data CSV (with the error-metric formula and any significance symbols noted), and a bar chart whose y-axis doesn't start at zero is flagged on the figure as visually exaggerating differences.
- Honest measurements — ROI area/intensity, line profile, cell count, and exposure analysis now disclose that they read the 8-bit display (after any brightness/contrast/gamma/LUT), not raw data — strengthened when the measured panel has adjustments applied. Exposure "saturated" is relabelled "clipped on the display".
- Full adjustments on freeform panels — gamma, black/white levels, fluorescence LUTs, grayscale, and invert are now reachable on freeform image elements (🎚 More…), matching grid mode and closing the largest grid↔freeform gap.
- Performance & polish — image adjustment sliders preview live as you drag (rAF-coalesced) instead of after a pause; the freeform render loop drops an O(n²) index lookup; and chart legends auto-size to fit long labels instead of clipping them.
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.
- Data charts from a CSV 📊 — drop a
.csv/.tsvor 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). - Honest statistics, enforced. Error bars are SD (sample, n−1 denominator) or SEM (SD/√n), named in the UI and in the generated code. FigureLab never computes a p-value — significance brackets are the symbol you enter from your own test, with the disclaimer in the editor. The whole dataset is edited in one textarea and replaced wholesale — no dragging bars or nudging points.
- Heatmaps & survival curves — omics-style value matrices with a mandatory colourbar and perceptually-ordered scales (viridis/magma/blues; a diverging scale requires an explicit midpoint). Kaplan–Meier survival curves (product-limit estimator) with censoring ticks — a documented plotting transform, with no log-rank / Cox inference computed.
- Chart code export — every chart emits self-contained matplotlib & ggplot code with the data inlined and the exact error-metric formula (
SEM = SD/sqrt(n), SD with ddof=1) written into a comment, plus a note that significance marks are author-supplied. - Flowcharts with real connectors — a connector line stays attached to two objects (by identity, not position) and reroutes as they move, resize, or rotate; it hit-tests along the polyline and exports as vector. One-click PRISMA 2020, CONSORT and fishbone (Ishikawa) templates, built from grouped box+label pairs and connectors, with editable placeholder counts. (Structure follows the named guidelines; no published diagram artwork is reproduced.)
- Group & ungroup (
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. - ~50 recolourable icons across cells, molecules, organisms & lab equipment, with a search box; one-click themes (grayscale-for-print, high-contrast, fluorescence-dark, poster) applied over the figure's chrome in a single undo step.
- Honest background removal — make a flat background transparent with a tolerance slider + eyedropper; it's a non-destructive adjustment (original pixels kept), disclosed in the provenance log, and undoes cleanly.
- Accessibility & disclosure — generate factual figure alt-text (rule-based, with an optional on-device polish) embedded in PNG metadata; AI-generated schematics are tagged in the layer list and export metadata, and generative AI is never applied to imported experimental images.
- Fixed: freeform figures were exporting at on-screen resolution (~1200×900) regardless of the DPI selector — they now supersample to the true target DPI like grid mode. Also fixed a per-frame recompute of freeform image adjustments during drags.
Focus: a friendly, direct-manipulation figure editor + opening the image formats scientists actually have — same single file, still offline & private.
- Friendly figure editor (Freeform mode) — click-to-select, drag-to-move, handle resize (aspect-locked for images), and a rotate handle that snaps to 0/45/90°. Objects snap into alignment with live magenta guides, and a floating context toolbar puts duplicate, lock, layer order & delete next to the selection.
- Per-object text — double-click text to edit it in place; set font family, size, bold/italic, colour and alignment per object.
- Cover patch 🩹 — the integrity-safe way to hide or replace baked-in text: an opaque, movable object (with match background) you type new text over. Reversible and recorded in the session — never a hidden pixel edit.
- Paint layer 🖌 — a brush & eraser that draw on a separate overlay, so your original image pixels are never modified. The layer appears in the layer list and every stroke is logged in the figure's provenance — non-destructive by design, so it can't be used to quietly alter data.
- Science icon library — drop in vector arrows, brackets, scale bars, cell/nucleus, significance asterisks & error-bar caps; they stay crisp and re-export as vector.
- Starter templates — 2×2 / 3×3 grids, before/after, or a freeform pathway diagram to start from.
- Pop out to free layout — convert a grid into freely-movable panels in one click.
- Format painter — copy one object's look (colour, fill, stroke, font) onto others.
- TIFF import — a dependency-free, client-side decoder opens
.tif/.tiffthat 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 atifffile-generated fixture matrix. - Scale auto-calibration — importing a TIFF with pixel-size metadata (OME-TIFF, ImageJ, Zeiss LSM, or a cm-resolution tag) sets µm/px automatically, so the scale bar is calibrated with no clicking. Print-DPI (inch resolution) is ignored so it's never mistaken for a real scale; you can always override.
- Recolourable science icons and one-click Relabel A, B, C… (renumber panels in reading order).
- SVG stays vector — imported SVGs keep their vector source (crisp at any size) and re-export as vector in SVG output, editable in Illustrator/Inkscape.
- Save dialog — every export opens a dialog to set name, format & DPI first; on Chrome/Edge you choose the folder in the OS's native Save window.
- Duplicate (
Ctrl+D), lock, and multi-object layer ordering for Freeform objects.
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 PowerPointwrites 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 AIbutton 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.
-
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 → Exportworkflow 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
👁 CVDtoolbar 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 aCITATION.cff/ Zenodo metadata for citing the tool; CI runs the Playwright suite (now 33 tests).
Also in v3.5 (workflow, integrity & onboarding):
- Full-session autosave & crash recovery — a debounced snapshot to local storage (with a "last autosaved" status and on/off toggle) offers to recover your work after an accidental close.
- Actionable journal audit — the compliance check adds scale-bar calibration, adjustment-sanity, and export-size checks, each with a one-click fix (Set 300 DPI, Make white, Calibrate, Show labels).
- Before/after adjustment slider — a per-panel wipe between original and adjusted pixels.
- Layer panel — rename / hide / lock annotations.
- Matched panels now also sync crop and physical field of view; western-blot mode adds lane labels, an MW ladder, and a blot-metadata CSV (with the splice marker).
- Smart import gains a filename pattern parser (
condition_time_channel_rep); crop editor gains arrow-key nudge and copy-crop-to-group/all. - More journal presets (eLife, PLOS, EMBO, PNAS); lightweight vs bundled session saves; house-style presets.
- First-run guided tour, PWA install (over http/https), SHA-256 provenance stamp in export metadata, and sRGB colour tagging in PNGs.
Make it yours:
- Session library — keep multiple named figures in your browser (IndexedDB, with thumbnails) and switch between them: save the current figure, then open / update / rename / delete any of them from Export → My sessions. All local.
- Personalized welcome — the start screen greets you by name and time of day. Set your name, theme, and the animated background from ⚙ Customize (also in the command palette).
- Animated science background — the drifting bio-graphics behind an empty canvas now include molecules and cells alongside phage, bacteria, DNA, and vesicles. Toggleable, and it respects
prefers-reduced-motion.
- Crisp on-screen preview on HiDPI displays — a separate high-resolution display layer renders the figure at devicePixelRatio once editing settles, so panel labels, scale bars, and annotations stay sharp when zoomed in. The figure buffer that measurements and exports read is left at logical resolution and untouched, so quantification stays exact
- Universal undo/redo — Ctrl+Z / Ctrl+Y now cover layout (grid size, gaps, margins, gutters), per-panel adjustments (brightness/contrast/gamma/LUT/levels/crop/rotate/flip), panel add/delete/duplicate/reorder, label toggles, and annotations — not just annotations. One undo step per edit gesture
- Reliability & UX hardening: corrupt-file and clipboard/export error toasts, export progress indicator, "render outdated" cue, first-run tips, confirm on Apply-to-all
- Performance: per-panel adjustment cache + rAF-coalesced renders for smooth slider dragging
- Accessibility: visible focus rings, aria-labelled icon buttons, on-screen-clamped floating toolbar, higher-contrast theme text
- Drag-to-space gutters — hover between panels and drag the divider (or tap + / −) to set spacing directly on the canvas
- Per-gutter spacing mode for independent row/column gaps, with one-click reset to uniform
- One-click label toggle — ⊞ Labels toolbar button and an on-canvas × chip
- Double-click to add text onto any panel, with an inline editor and a readability halo (also exported to SVG)
- True high-resolution raster export — PNG/JPEG/WebP re-rendered at the real target-DPI pixel count, with high-quality image smoothing throughout
- Rotate and flip per panel
- Gamma correction slider
- Auto scale bar (picks a clean µm value)
- Apply settings to all panels
- Double-headed arrow (Arrow2) annotation
- Ruler annotation with tick marks
- Cell counter tool
- Filled rect/ellipse with opacity
- Shift-key snap (45° angles, square shapes)
- Label format selector (A/a/1/i)
- Panel border styling
- CSV metadata export
- Zoom to fit / 1:1 / Copy to clipboard buttons
- Empty panel slot shows dashed border and slot number
- Info bar shows physical size in mm
- Print-ready CSS (
Ctrl+Phides all UI chrome) - Notes scratchpad saved in session JSON
- Lock panel to prevent drag-reorder
- Fix: annotation drag is smooth (live preview on overlay canvas)
- Fix: TIFF exports at correct DPI (pixel count scaled, not just tag)
- Fix: SVG export uses native vector elements for annotations
- Full annotation system (arrow, rect, ellipse, line, text, inset)
- Panel annotations pinned to individual panels
- Interactive crop editor
- Multi-channel fluorescence composite
- Fluorescence LUTs
- Black/white point clipping
- Calibration tool
- Journal compliance checker
- Undo/redo history
- Reproducibility R/Python scripts
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.

