Skip to content

stylo: servo's `Device::app_units_per_device_pixel` truncates instead of rounding

Generated from repros/stylo-app-units-per-device-pixel-truncated/README.md when the site was built at 7aa23e2.

On the Android emulator (device pixel ratio 2.625), a 36px tall box-sizing: border-box button with a 3px border and 24px of side padding comes out 53.86px wide in niso and 53.33px in Chrome: stylo computes each 3px border as 2.933px. niso cannot fix the computed value. Its core copies stylo’s snapping in crates/niso-core/src/controls.rs stock_field (let per_device_px = (60.0 / scale) as i32;, on the control-tints, field-styles, hailgauge and radio branches) only so that its stock-field check agrees with what stylo computes; that copy should switch to rounding when stylo does.

Device::app_units_per_device_pixel (style/device/servo.rs:226-228) is (AU_PER_PX as f32 / device_pixel_ratio) as i32, which truncates. At ratio 2.625 that is 22 app units per device pixel where 60 / 2.625 = 22.857. Gecko rounds the same quotient: nsDeviceContext::ComputeAppUnitsPerDevPixelForWidgetScale returns std::max(1, NSToIntRound(float(aUnzoomedAppUnits) / aFullZoom)) (gfx/src/nsDeviceContext.cpp:62), which gives 23.

snap_as_border_width (style/values/specified/border.rs:235-246) snaps border widths down to whole multiples of that unit, so with 22 a 3px (180au) border becomes 8 × 22 = 176au = 2.933px, more than its 7 device pixels (7 / 2.625 = 2.667px). With Gecko’s 23 it is 7 × 23 = 161au = 2.683px; Chrome floors to whole device pixels and gets 2.667px.

A 400x400 CSS px viewport at device pixel ratio 2.625, <div style="border: 3px solid">, its left border from unrounded_layout():

border: 3px solid at device pixel ratio 2.625
expected: 2.667 (7 device pixels, Chrome) to 2.683 (161 app units, Firefox)
actual: 2.9333334 (176 app units)

Every ratio where 60 / ratio has a fraction of 0.5 or more is affected, among them the common Android densities 2.625 (22.86 → 22) and 2.75 (21.82 → 21), 2.25 (26.67 → 26), and the Pixel 9 Pro Fold’s 2.4375 (24.62 → 24). On the fold a 1px border comes out 2 × 24au = 0.8px (niso’s layout reports 0.797px) where Chrome’s is 0.8205px (2 device pixels), so a list of rows with 1px borders drifts from Chrome’s by about 0.06 device pixels a row.

Terminal window
CARGO_TARGET_DIR=~/.capy/work/repro-target cargo run

Exit status 1 while the bug reproduces, 0 once fixed. On Ubuntu, blitz needs sudo apt-get install -y libfontconfig-dev pkg-config. The repro goes through blitz-dom at niso’s pin (blitz be60597, which uses stylo 2dca34592ced37c6aa01733e94206a767194cf9d) because that is how niso meets it; the bug is in stylo alone.

Same code on servo/stylo main at 7f0189de128e6dbd2b504acd3aebee3befa78358 (2026-11-06): neither style/device/servo.rs nor style/values/specified/border.rs changed in the 8 commits since niso’s pin (git diff 2dca345 7f0189de on both files is empty), so it reproduces there. Running the repro against stylo main itself is not possible: blitz be60597 does not build with it (stylo_static_prefs no longer has a pref blitz-dom reads, dom.select.customizable_select.enabled).

None found. Searched servo/stylo and servo/servo issues and PRs for app_units_per_device_pixel, au_per_dev_px, snap border width and border width device pixel, and the web. Related but different: servo/servo#27457 (open, border widths inconsistent at ratio 1.5, a WebRender painting issue; 60 / 1.5 is exact) and servo/servo#35534 (open, border rendering under scaling, WebRender). DioxusLabs/blitz#1076 (open) is about a document keeping the border widths of its first scale, not this rounding.

Title: servo Device::app_units_per_device_pixel truncates; Gecko rounds

Body:

style/device/servo.rs computes app units per device pixel as (AU_PER_PX as f32 / self.extra.media_data.device_pixel_ratio.0) as i32, which truncates. Gecko rounds the same quotient (nsDeviceContext::ComputeAppUnitsPerDevPixelForWidgetScale: NSToIntRound(float(aUnzoomedAppUnits) / aFullZoom)). At device pixel ratio 2.625, a common Android density, servo gets 22 where Gecko gets 23 (60 / 2.625 = 22.857), and snap_as_border_width then snaps a 3px border to 8 × 22 = 176au = 2.933px, wider than the 7 device pixels (2.667px) Chrome draws and the 161au (2.683px) Firefox computes. Bordered boxes come out wider than in other engines at such ratios (2.75 and 2.25 too).

Repro through blitz (any servo-engine embedder shows it):

use blitz_dom::DocumentConfig;
use blitz_html::HtmlDocument;
use blitz_traits::shell::{ColorScheme, Viewport};
fn main() {
let html = r#"<!DOCTYPE html><body style="margin:0"><div id="box" style="border:3px solid;width:10px;height:10px"></div>"#;
let config = DocumentConfig { viewport: Some(Viewport::new(1050, 1050, 2.625, ColorScheme::Light)), ..Default::default() };
let mut doc = HtmlDocument::from_html(html, config);
doc.resolve(0.0);
let id = doc.get_element_by_id("box").unwrap();
println!("{}", doc.get_node(id).unwrap().unrounded_layout().border.left); // 2.9333334; Firefox 2.683, Chrome 2.667
}

Tested at 2dca34592ced37c6aa01733e94206a767194cf9d and main 7f0189de128e6dbd2b504acd3aebee3befa78358. Suggested fix: round, as Gecko does: ((AU_PER_PX as f32 / dpr).round() as i32).max(1).