Logo by Sanya — t.me/SanyochekDev
A tiny, fast browser engine written in Rust.
Renders HTML, CSS, JavaScript, SVG and images — to a PNG, or to a live interactive window.
Quick start · Interactive mode · Features · Architecture · Contributing
Falco is a real browser engine in roughly 36,000 lines of Rust. It parses HTML, applies CSS, executes JavaScript, loads images, computes layout, and paints to a canvas — either as a PNG file or a live interactive window where you can scroll, click links, fill out forms, and navigate.
HTML ──▶ DOM ──▶ Style tree ──▶ Layout tree ──▶ Paint commands ──▶ Canvas ──▶ PNG / Window
▲ ▲ ▲
│ │ │
HTML5 tokenizer CSS cascade Flex / Grid / Table / Float / Absolute
+ tree builder + Selectors 4 + Inline / Block flow
│
JS (custom VM with closures, generators, Promise, BigInt, Symbol)
│
Image loader ──▶ HTTP / data: URL / local file
Falco is not a wrapper around WebKit, Gecko, or Chromium. Every module — HTML tokenizer, CSS parser, layout engine, JS VM, font rasterizer, PNG encoder — is written from scratch in Rust.
To set expectations clearly — this is v0.1.0, an early release by a single developer. Not everything listed in this README is production-ready. Here's what is actually wired into the render pipeline and what is structurally complete but not yet called:
- HTML parser (
html/) — legacy parser, not spec-compliant but functional - DOM types (
dom/) — minimal node types, no observers/shadow - CSS parser (
css/) — selectors, properties, cascade, color parsing - Style cascade (
style/) — UA styles + inheritance + flex/grid props - Layout (
layout/) — block / inline / flex / CSS Grid / table / float / absolute - Painting (
paint/) — fonts (ab_glyph), gradients, shadows, alpha compositing - SVG renderer (
svg/) — paths, basic shapes, gradients, stroke + fill - Hand-written PNG encoder (
png/) - Image loader (
image/) — HTTP, data: URLs, local files - JS VM (
tjs/) — bytecode interpreter + JIT (x86_64, Linux-only) - JS-DOM bindings (
js_tjs/,js_runner/) —document.getElementById,console.log,alert,onclick - Networking (
net/) — HTTP/1.1 (ureq), cookies, cache, websocket, redirect - Interactive
--windowmode — scrolling, forms, navigation, history
These exist as spec-compliant replacements for the legacy modules. They
compile and have their own unit tests, but render_with_base_url does
not call into them yet. This is the v0.2.0 milestone.
html::spec— WHATWG §13.2 tokenizer (all 80 states) + tree builder (all 22 insertion modes) + serializer + XML parser + encoding detectiondom::spec— spec DOM with MutationObserver, Shadow DOM, custom elements, accessibility treecss::spec— Selectors Level 4 (:has(),:is(),:where(), cascade layers, container queries)tjs_ext/— Symbol, BigInt, Promise, microtasks, Map/Set, WeakMap/WeakSet, Reflect
security/— SOP, multi-process, seccomp sandbox, CSP, TLS cert chain validation, permissions, extensions, DevTools protocol. All algorithms are in place and unit-tested, but the renderer doesn't enforce them yet.web_runtime/—fetch(),XMLHttpRequest, event loop, Promise. The Promise / event loop integration is real and unit-tested, butfetchandXHRare stubbed (no real network behind them in the JS context). WebGL, video, MSE, EME, NDSD are headless stubs — they implement the API surface but don't actually render WebGL frames, decode H.264 video, or do DRM. They exist as scaffolding for future work.media/—@mediaqueries are parsed but always applied (no conditional cascade yet)
real-http2,real-webgl,sandboxCargo features do not compile with--all-featuresbecause their upstream APIs have drifted (h2::Body removed, glow API changed, seccomp pre_exec is Unix-only). They are disabled by default and the CI clippy step does not check them.- The JIT (
tjs/jit.rs) works on Linux x86_64 but fails on macOS CI runners becausemmap(MAP_JIT)requires code signing with thecom.apple.security.cs.allow-jitentitlement. JIT tests are marked#[ignore]on macOS. - DOM mutation from JS (
element.innerHTML = ...,element.style.color = ...) does not trigger re-render. - HTML5 spec-compliant tree repair (adoption agency, foster parenting) is in
html/spec/tree_builder.rsbut the legacy parser is what actually runs.
Bottom line: if you cargo build && ./falco https://example.com --out out.png, you get a real PNG render. The HTML/CSS/layout/paint path works end-to-end. The spec-compliant parsers, security enforcement, and advanced web runtime (WebGL/video/DRM) are scaffolding for future milestones, not working features. Please read the code before claiming otherwise.
# Build (release binary lands in target/release/falco)
cargo build --release
# Render a local HTML file to PNG
./target/release/falco page.html --out page.png --width 1200
# Render a URL to PNG
./target/release/falco https://example.com --out example.png --width 800
# Open interactive live window (desktop only — scrolls, hover, link clicks)
./target/release/falco page.html --window
# Render with external CSS merged on top of <style> tags
./target/release/falco README.html --css style.css --out readme.png<style> tags inside the HTML are automatically extracted and
applied. External CSS via --css is merged on top.
When you pass --window, Falco opens a real browser window with an
address bar at the top, the page content in the middle, and a
status bar at the bottom. You can:
- Click links (
<a href>) — Falco fetches the new URL and re-renders - Address bar — click it or press
F6, type a URL, pressEnter - Reload with
r— re-fetches the current page - Back/Forward with
Alt+←/Alt+→— full history navigation
- Click inputs to focus them, then type to enter text (live update)
- Tab / Shift+Tab — cycle focus between interactive elements
- Backspace — delete the last character
- Enter — submit form / click the focused button / follow focused link
- Supported types:
text,email,password(masked),checkbox,submit,button,textarea
- Mouse wheel, arrow keys, Page Up/Down, Home/End
- Focus ring — 2px blue outline around the focused element
- Blinking caret — 500ms blink in text inputs and the address bar
- Hover hint — when hovering a link, its href appears in the status bar
The window only re-paints when state actually changes (scroll, input text, focus, hover). On idle frames, only the address bar / status bar overlays are redrawn — the page canvas is cached. This keeps CPU usage low and scrolling smooth.
If no display is available (headless server, no X11/Wayland), Falco automatically falls back to PNG output with a warning.
falco — a tiny browser engine
USAGE
falco <input> [OPTIONS]
INPUT
A URL (http://...) or a path to a local .html file.
OPTIONS
--css <path> External CSS file (merged with <style> tags in HTML).
--out <path> Output PNG path. Default: falco.png
--width <px> Viewport width. Default: 1200
--height <px> Viewport height (canvas grows if content is taller). Default: 800
--bg <hex> Background color (0xRRGGBBAA). Default: 0xFFFFFFFF
--window Open a live interactive window instead of writing PNG.
-h, --help Show this help
-V, --version Print version
INTERACTIVE MODE KEYS (when --window is used)
q / Esc quit
r reload (prints message — restart Falco to actually reload)
↑ / ↓ scroll line
PgUp / PgDn scroll page
Home / End jump to top / bottom
g / G top / bottom (vim-style)
mouse wheel scroll
mouse click follow `<a href>` link (prints URL to stderr)
use falco::{render_to_png, RenderOptions};
fn main() -> anyhow::Result<()> {
let html = std::fs::read_to_string("page.html")?;
let css = std::fs::read_to_string("style.css")?;
let opts = RenderOptions {
width: 1200,
height: 800,
background: 0xFFFFFFFF,
};
render_to_png(&html, &css, opts, "out.png")?;
Ok(())
}- HTML5 tokenizer (WHATWG §13.2.5) — all 80 states, script-data escape/double-escape, attribute parsing with duplicate detection, named + numeric character references with Windows-1252 quirks
- HTML5 tree builder (WHATWG §13.2.6) — all 22 insertion modes,
stack of open elements with scope algorithms, active formatting
elements list, reconstruct active formatting, adoption agency
algorithm, foster parenting for table content,
<template>with separate DocumentFragment contents - Entity references —
&,A,©, 50+ named entities - Whitespace collapsing (browser-style)
<template>element with separate DocumentFragment contents- XML/XHTML parser — strict, with namespace bindings, CDATA, PIs
- Encoding detection — BOM, HTTP
Content-Typecharset,<meta charset>,<meta http-equiv>, heuristic UTF-8/UTF-16 detection, decoders for UTF-8 / UTF-16LE / UTF-16BE / Windows-1252 - Tree builder auto-close —
<li>,<p>,<td>,<tr>,<option>,<dt>/<dd> - innerHTML / outerHTML serialization — void elements,
<template>contents fragment, raw text elements, full attribute value escaping
NodeRef = Rc<RefCell<Node>>withparent,firstChild,lastChild,previousSibling,nextSiblingpointers per specDocumentHandle = Rc<RefCell<Document>>with weak back-ref from each Node- Mutation records queued on every append/insert/remove/setAttribute/removeAttribute
- MutationObserver with
observe(),disconnect(),take_records(),MutationObserverInit(childList/attributes/characterData/subtree/attributeOldValue/ characterDataOldValue/attributeFilter), subtree ancestor matching - Shadow DOM —
attachShadow()with open/closed modes, host validation, named + default slots, fallback content, slot distribution (flatten tree algorithm),assignedSlotlookup - Custom elements —
customElements.define()with name validation,observedAttributestracking, lifecycle callbacks (connected / disconnected / adopted / attributeChanged / form-associated), pending upgrades, customized built-in elements (is="...") - Accessibility tree — parallel tree with role/name/description/
state/actions, implicit ARIA roles for ~50 HTML tags, honors
role="",aria-hidden,hidden,display:none, accessible name computation (aria-label > aria-labelledby > element-specific > title)
- Selectors Level 4 — type, class, id, universal (
*), descendant, child (>), adjacent sibling (+), general sibling (~), attribute ([attr],=,~=,|=,^=,$=,*=) - Pseudo-classes —
:hover,:focus,:focus-visible,:focus-within,:active,:visited,:checked,:disabled,:enabled,:readonly,:readwrite,:required,:optional,:valid,:invalid,:empty,:root,:first-child,:last-child,:only-child,:first-of-type,:last-of-type,:only-of-type,:nth-child(an+b),:nth-last-child,:nth-of-type,:nth-last-of-type,:nth-child(an+b of S),:is(),:where(),:not(),:has(),:lang(),:dir() - Cascade & specificity — (a, b, c) tuple,
:where()zero,:is()/:not()/:has()most specific arg, CascadeOrigin (UA / User / Author) with reversed order for!important, CascadeLayers (None wins over layered; later wins over earlier) - Properties —
display,position,color,background(includinglinear-gradientandradial-gradient),font-*,margin,padding,border,border-radius,width,height,min/max-width,top/right/bottom/left,z-index,overflow,opacity,box-shadow,white-space,box-sizing,gap,flex*,grid*,writing-mode, logical properties (margin-inline-start, etc.) - Values — keywords, hex/rgb/rgba/named colors, lengths (px, em,
rem, pt, %, vw, vh), percentages, numbers,
!important - Functions —
linear-gradient(),radial-gradient(),url(),var(),calc()(simplified),rgb(),rgba() - Shorthands —
margin,padding,border,background,flex - @-rules —
@media(parsed and applied),@keyframes/@animationwith cubic-bezier & steps timing functions,@font-facewith family/weight lookup,@layer(cascade layers),@containerqueries withevaluate_container_query() - Logical properties —
margin-inline-startetc. resolved to physical properties based onwriting-mode - CSS counters —
counter-reset,counter-increment,counter-set - Containment —
contain: layout/paint/size/style/inline-size/ block-size,strict,content - Filters —
blur,brightness,contrast,drop-shadow,grayscale,hue-rotate,invert,opacity,saturate,sepia - Clip-path —
polygon,circle,ellipse,inset,path,url()
- Block flow — vertical stacking
- Inline flow — horizontal text wrapping with proper baseline
- Flexbox —
flex-direction,justify-content,align-items,flex-wrap,gap,flex-grow,flex-shrink,flex-basis - CSS Grid —
grid-template-columns/grid-template-rows(withfr,auto,minmax(),repeat()),grid-column/grid-rowplacement,gap/column-gap/row-gap,auto-flow - Table layout —
<table>,<tr>,<td>,<th>,<thead>,<tbody>,<tfoot>,<caption>, column width distribution, border collapse - Float —
float: left/right, simple clear - Inline-block — inline elements with block-like width/height
- Box model — margin, border, padding, content with
box-sizing: border-boxsupport - Position —
static,relative,absolute,fixed(parsed, relative + absolute positioning applied) - Units — px, em, rem, pt, %, vw, vh
- Writing modes —
horizontal-tb,vertical-rl/lr,sideways-rl/lr
- Backgrounds — solid colors and linear / radial gradients
- Borders — all four sides with custom colors and styles
- Border-radius — rounded corners (all four corners)
- Box-shadow — outer shadows with blur
- Opacity — alpha blending for entire elements
- Text — TrueType font rasterization via
ab_glyph, bold and italic synthesis - Alpha compositing — proper RGBA blending
- SVG — paths, basic shapes (
rect,circle,ellipse,line,polyline,polygon), gradients, stroke + fill - PNG encoder — hand-written, no
flate2dependency
Falco ships its own JavaScript VM (the tjs module) — pure Rust, no
V8/SpiderMonkey/boa. It is a bytecode VM with a generational GC,
inline caching, hidden classes, and a JIT tier-up.
- ES2015+ syntax —
let/const, arrow functions, template literals, destructuring (object + array), default + rest params, spread,for...of,for...in, computed property names, shorthand methods/properties, optional chaining, nullish coalescing, exponentiation operator, async/await (parsed) - Functions —
function foo() {}, closures with proper upvalue capture, generators (function*/yield/yield*),async function/await(parser-level) - Types —
Symbolwith 13 well-known symbols,BigIntwith arbitrary precision (u32 limbs, signed),Promisewiththen/catch/finally+ state machine,Iteratorprotocol,Generatoras state machine - Built-ins —
Math,JSON,Array(push,pop,map,filter,reduce,forEach,find,findIndex,includes,slice,splice,flat,flatMap),String(split,replace,match,padStart,padEnd,trim,trimStart,trimEnd,startsWith,endsWith,includes,repeat),Object(keys,values,entries,assign,freeze,fromEntries),Reflect(get,set,has,deleteProperty,ownKeys),WeakMap,WeakSet,Map,Set,Proxy - Microtask queue —
Promisereactions drained as microtasks - DOM bindings —
document.getElementById,document.querySelector/querySelectorAll,console.log,alert,addEventListener(basic) - Event loop integration —
setTimeout,setInterval,requestAnimationFrame,fetch()(returnsPromise<Response>),XMLHttpRequest, all driven by the event loop inweb_runtime/event_loop.rs - console.log(...) — prints to stderr
- alert(msg) — shows in the status bar
- HTTP/1.1 fetch (via
ureq) - HTTP/2 parser (
http2.rs) - Cookie jar (
cookies.rs) with proper domain/path matching - Redirect handling (
redirect.rs) with redirect-loop detection - Cache (
cache.rs) — HTTP cache with conditional requests - WebSocket (
websocket.rs) — frame parser, masking, ping/pong
fetch()(fetch.rs) — Promise-based, integrates with event loopXMLHttpRequest(xhr.rs) — sync + async modes- Event loop (
event_loop.rs) — task queues, microtasks, RAF Promise(promise.rs) — state machine, then/catch/finally- WebGL (
webgl.rs) — shader compilation, buffer management, draw calls (headless) - Video (
video.rs) —<video>element demux + decode stub - MSE (
mse.rs) — Media Source Extensions - EME (
eme.rs) — Encrypted Media Extensions - NDSD (
ndsd.rs) — Native Device Service Discovery
- Origin / SOP (
origin.rs) — Origin struct,is_same_origin,is_same_site,registrable_domain(with 2-part TLD list),check_cors(with credentials / wildcard handling),check_navigation - Multi-process / site isolation (
process.rs) — Process kinds (Browser / Renderer / GPU / Utility / Plugin), site-to-process map, ProcessPerSite / ProcessPerTab policies, crash recovery with max-restarts / sad-tab / fatal modes - Sandbox (
sandbox.rs) — seccomp-bpf filter (Linux), renderer allowlist (~30 syscalls), blocksexecve/fork/ptrace/open/socket/connect/mount,PR_SET_NO_NEW_PRIVS,drop_capabilities - CSP (
csp.rs) — directive map,default-srcfallback, source expression matching ('self','none','unsafe-inline','unsafe-eval',data:,blob:, host,*.wildcard,scheme:), nonce/hash support,allows_inline_script,allows_eval,allows_javascript_url,is_safe_attribute(blocksonclick,onerror,javascript:in href/src), violation reports - TLS certificates (
cert.rs) — Certificate struct, validity, hostname matching (with wildcards), TrustStore with Mozilla defaults,validate_chain(chain building, signature check, hostname, EKU, path length), OCSP stub, HPKP pinning, Certificate Transparency - Permissions (
permissions.rs) — 20 permission types (Geolocation, Camera, Microphone, Notifications, ...), per-(origin, permission) state, pluggable prompt handler, iframe allow parsing - Extensions (
extensions.rs) — Manifest V3, content scripts, match patterns (<all_urls>,*://*.host/*), glob matching, permissions, generate extension ID, ChromeApi enum - DevTools protocol (
devtools.rs) — JSON value type, Request/Response/Event/RpcError, Inspector/Page/Runtime/DOM/Network/ Console methods, event subscribers, console message buffering
- HTTP/HTTPS URLs — fetched via
ureq data:URLs — base64-encoded inline images- Local files — relative paths resolved against the page URL
- Formats — PNG, JPEG, GIF (first frame), BMP (via the
imagecrate) - Sizing —
width/heightHTML attributes take precedence, CSSwidth/heightrespected, default 300×200px, nearest-neighbor scaling - Broken images — grey placeholder box with the
alttext - Caching — global cache by URL
- The
html::spec,dom::spec,css::spec,tjs_ext/,security/modules are structurally complete but not yet wired into the render pipeline. Falco still uses the legacyhtml/dom/cssmodules for actual rendering. The new modules exist as the spec-compliant replacements and pass their own unit tests, but the renderer has not been switched over yet. - No CSS animations / transitions in the renderer (the data structures
exist in
css/spec/advanced.rs, but the paint loop does not interpolate them). - No
@mediaquery value matching in the renderer (queries are parsed; the cascade does not yet apply them conditionally). - No real DOM mutation from JS in the renderer (
element.innerHTML = ...,element.style.color = ...are stubs). - No HTML5 spec-compliant tree repair in the renderer (the algorithm
exists in
html/spec/tree_builder.rsbut the legacy parser is used).
| Module | Lines | Description |
|---|---|---|
html::spec |
~4,900 | WHATWG HTML5 tokenizer + tree builder + serializer + XML parser + encoding |
html.rs |
~540 | Legacy HTML parser (still used in render pipeline) |
dom::spec |
~2,280 | Spec-compliant DOM, MutationObserver, Shadow DOM, custom elements, a11y |
dom.rs |
~140 | Legacy DOM (still used in render pipeline) |
css::spec |
~2,020 | Selectors L4, cascade specificity, @-rules, animations, containment, filters |
css/ |
~1,660 | Legacy CSS parser + selector matching + color parsing |
style/ |
~1,720 | Style cascade + UA styles + inheritance + flex/grid properties |
layout/ |
~1,950 | Block / inline / flex / grid / table / float / absolute layout |
paint/ |
~470 | Canvas + font rasterizer + alpha compositing + gradients + shadows |
svg/ |
~1,130 | SVG parser + renderer (paths, shapes, gradients) |
tjs/ |
~4,340 | Custom JS VM: lexer, parser, interpreter, bytecode VM, JIT, value, builtins |
tjs_ext/ |
~780 | Symbol, BigInt, Promise, microtasks, Map/Set, WeakMap/WeakSet, Reflect |
js_tjs.rs |
~700 | JS-to-DOM bindings (document, console, alert, onclick) |
web_runtime/ |
~4,300 | fetch, XHR, event loop, Promise, WebGL, video, MSE, EME, NDSD, HTTP/2 |
net/ |
~930 | HTTP fetch, cookies, cache, websocket, redirect |
security/ |
~3,590 | SOP, multi-process, sandbox, CSP, certs, permissions, extensions, DevTools |
window/ |
~1,110 | Interactive window: scrolling, forms, navigation, history, address bar |
image/ |
~200 | Image loader (HTTP, data: URLs, local files) + cache + scaling |
png/ |
~100 | Hand-written PNG encoder (no flate2 dependency) |
main.rs, lib.rs |
~550 | CLI parsing + library entry points |
| Total | ~36,000 |
falco/
├── .github/ # CI, issue templates, contributing, security policy
│ ├── workflows/
│ │ ├── ci.yml # fmt + clippy + build + test on 3 OSes × 2 toolchains
│ │ └── release.yml # Build per-OS release binaries on tag push
│ ├── ISSUE_TEMPLATE/ # bug_report.md, feature_request.md, config.yml
│ ├── CONTRIBUTING.md
│ ├── CODE_OF_CONDUCT.md
│ ├── SECURITY.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── FUNDING.yml # MonoBank donation link
│ └── dependabot.yml
├── docs/ # Logo
│ └── falco.svg # project logo (by Sanya)
├── src/
│ ├── main.rs
│ ├── lib.rs
│ ├── html/spec/ # WHATWG HTML5 (tokenizer, tree builder, ...)
│ ├── html.rs # legacy HTML parser
│ ├── dom/spec/ # spec DOM (observer, shadow, custom elements, a11y)
│ ├── dom.rs # legacy DOM
│ ├── css/spec/ # selectors L4, cascade, @-rules
│ ├── css/ # legacy CSS parser
│ ├── style/ # cascade + inheritance + UA styles
│ ├── layout/ # block/inline/flex/grid/table/float/absolute
│ ├── paint/ # canvas + fonts + compositing
│ ├── svg/ # SVG parser + renderer
│ ├── tjs/ # custom JS VM (lexer, parser, VM, JIT)
│ ├── tjs_ext/ # Symbol, BigInt, Promise, ...
│ ├── web_runtime/ # fetch, XHR, event loop, WebGL, video, MSE, EME
│ ├── net/ # HTTP, cookies, cache, websocket, redirect
│ ├── security/ # SOP, sandbox, CSP, certs, permissions, DevTools
│ ├── window/ # interactive window mode
│ ├── image/ # image loader
│ └── png/ # PNG encoder
├── Cargo.toml
├── Cargo.lock
├── LICENSE
└── README.md
The next big pieces of work, roughly in priority order:
- Wire
html::spec+dom::specinto the render pipeline — switch off the legacyhtml+dommodules. This unlocks spec-compliant tree repair, full MutationObserver, and real custom elements. - Wire
css::specinto the cascade — get:is/:where/:hasworking in real rendering, plus cascade layers and container queries. - DOM mutation from JS —
element.innerHTML = ...,element.style.color = ..., full re-render on mutation. - CSS animations / transitions — interpolate keyframes in the paint loop, run them through the event loop.
- Wire
security/into the renderer — SOP enforcement in DOM access, CSP in the script runner, certificate validation on HTTPS fetches, multi-process sandbox. @mediaquery value matching — apply queries conditionally based on viewport size.
If Falco is useful to you, consider buying the author a coffee:
MIT — see LICENSE.