Improve float, gradient, and script rendering parity

This commit is contained in:
SGavrl committed 2026-08-04 05:37:03 +00:00
1 parent 0562fb4921
commit 03f506b01a
7 files changed
+974 -86

No files matched your search

+62 -21
View File
@@ -189,13 +189,20 @@ if (_origStackDesc && _origStackDesc.get) {
}
let _fpSeed = 0;
// Dynamic script import queue — serializes concurrent import() calls
// to prevent re-entrant RefCell panic in deno_core's futures_unordered_driver
// when SPAs dynamically insert multiple <script module> tags at once.
// Dynamic module/in-order script queue. Module evaluation remains serialized
// to prevent a re-entrant RefCell panic in deno_core's
// futures_unordered_driver when SPAs insert multiple <script type=module>
// elements at once. Ordinary dynamically inserted classic scripts are async
// by default, so their fetches run independently and execute when ready just
// like browser ScriptRunner tasks; serializing those fetches made unrelated
// analytics/widgets form one long load-blocking waterfall.
let __dynScriptQueue = [];
let __dynScriptBusy = false;
let __dynClassicPending = 0;
Object.defineProperty(globalThis, '__obscura_hasPendingDynamicScripts', {
value: function() { return __dynScriptBusy || __dynScriptQueue.length > 0; },
value: function() {
return __dynClassicPending > 0 || __dynScriptBusy || __dynScriptQueue.length > 0;
},
writable: false,
enumerable: false,
configurable: false,
@@ -251,15 +258,7 @@ function _decodeDataScriptUrl(url) {
globalThis.__markParserScripts = function(nids) {
for (const nid of nids || []) Deno.core.ops.op_script_mark_started(+nid);
};
async function __processDynScriptQueue() {
if (__dynScriptBusy) return;
__dynScriptBusy = true;
// try/finally so the busy flag is always cleared even if a task throws
// outside its own guard; otherwise the queue would wedge and silently
// block every later dynamic script on the page.
try {
while (__dynScriptQueue.length > 0) {
const task = __dynScriptQueue.shift();
async function __runDynScriptTask(task) {
try {
if (task.isModule) {
await import(task.url);
@@ -268,34 +267,64 @@ async function __processDynScriptQueue() {
if (task.url.startsWith('data:')) {
body = _decodeDataScriptUrl(task.url);
} else {
const raw = await Deno.core.ops.op_fetch_url(task.url, "GET", "{}", "", task.pageOrigin, "no-cors", "same-origin");
const raw = await Deno.core.ops.op_fetch_url(
task.url, "GET", "{}", "", task.pageOrigin, "no-cors", "same-origin"
);
const parsed = JSON.parse(raw);
// The HTML script-fetch algorithm treats an unsuccessful HTTP
// response as a network error. Evaluating its response body is both
// observably unlike browsers and dangerous: JSON error payloads
// become JavaScript source, while servers can return diagnostic
// HTML that mutates global state before throwing.
// observably unlike browsers and dangerous: JSON error payloads and
// diagnostic HTML must never become script source.
if (!(parsed.status >= 200 && parsed.status <= 299)) {
throw new Error('HTTP ' + (parsed.status || 0));
}
body = parsed.body;
}
if (body) {
// A fetched async script is executed by a ScriptRunner task, not by
// the fetch promise's microtask continuation. Besides matching event
// loop ordering, this prevents a batch of concurrently completed
// third-party scripts from being charged to (and pinning) whichever
// parser script happened to trigger the microtask checkpoint.
await new Promise(resolve => {
const execute = () => {
globalThis.__currentScriptNid = task.nid;
try { (0, eval)(body); }
catch(e) { console.error('Dynamic script error (' + task.url + '):', e.message); }
finally { globalThis.__currentScriptNid = task.prevNid || 0; }
resolve();
};
if (_scheduleAfter(0, execute) === undefined) execute();
});
}
}
// Fire load via dispatchEvent only: it invokes the element's onload
// property handler and any addEventListener('load') listeners, read
// live off the element. Calling onload separately would double-fire it.
// property handler and any addEventListener('load') listeners, read live
// off the element. Calling onload separately would double-fire it.
try { task.dispatchEvent(new Event('load')); } catch(e) {}
} catch(e) {
console.error('Dynamic script fetch error:', e.message);
try { task.dispatchEvent(new Event('error')); } catch(ex) {}
}
}
async function __runAsyncClassicScript(task) {
__dynClassicPending++;
try {
await __runDynScriptTask(task);
} finally {
__dynClassicPending--;
}
}
async function __processDynScriptQueue() {
if (__dynScriptBusy) return;
__dynScriptBusy = true;
// try/finally so the busy flag is always cleared even if a task throws
// outside its own guard; otherwise the queue would wedge and silently
// block every later module or explicitly in-order script on the page.
try {
while (__dynScriptQueue.length > 0) {
await __runDynScriptTask(__dynScriptQueue.shift());
}
} finally {
__dynScriptBusy = false;
}
@@ -1290,15 +1319,27 @@ function __prepareInsertedScript(script) {
fullUrl = src;
}
const pageOrigin = (function() { try { return new URL(baseUrl).origin; } catch(e) { return ""; } })();
__dynScriptQueue.push({
const task = {
url: fullUrl,
isModule,
nid: script._nid,
prevNid,
pageOrigin,
dispatchEvent: (ev) => { try { script.dispatchEvent(ev); } catch(e) {} },
});
};
// A non-parser-inserted classic script is force-async unless script code
// explicitly assigned `.async = false`. Keep that opt-out in insertion
// order; default/async=true scripts fetch concurrently and execute as soon
// as each response is ready.
const explicitlyInOrder = !isModule
&& Object.prototype.hasOwnProperty.call(script, 'async')
&& script.async === false;
if (!isModule && !explicitlyInOrder) {
__runAsyncClassicScript(task);
} else {
__dynScriptQueue.push(task);
__processDynScriptQueue();
}
} else if (isModule) {
const dataUrl = 'data:text/javascript;base64,' + btoa(unescape(encodeURIComponent(code)));
__dynScriptQueue.push({
+58
View File
@@ -10730,6 +10730,64 @@ mod tests {
);
}
#[tokio::test(flavor = "current_thread")]
async fn dynamic_classic_scripts_are_async_by_default_but_honor_async_false_order() {
let mut rt = setup_runtime("<html><head></head><body></body></html>");
let result = rt
.call_function_on_for_cdp(
r#"async () => {
const originalFetchOp = Deno.core.ops.op_fetch_url;
const runPair = async (explicitlyInOrder) => {
globalThis.__dynamicOrder = [];
Deno.core.ops.op_fetch_url = (url) => new Promise(resolve => {
const slow = url.includes("slow");
setTimeout(() => resolve(JSON.stringify({
status: 200,
headers: {"content-type": "text/javascript"},
body: `globalThis.__dynamicOrder.push("${slow ? "slow" : "fast"}")`,
url,
})), slow ? 30 : 1);
});
const load = name => new Promise(resolve => {
const script = document.createElement("script");
if (explicitlyInOrder) script.async = false;
script.src = `/${name}.js`;
script.onload = resolve;
document.head.appendChild(script);
});
await Promise.all([load("slow"), load("fast")]);
return globalThis.__dynamicOrder.slice();
};
try {
const asyncOrder = await runPair(false);
const inOrder = await runPair(true);
return {
asyncOrder,
inOrder,
pending: globalThis.__obscura_hasPendingDynamicScripts(),
};
} finally {
Deno.core.ops.op_fetch_url = originalFetchOp;
}
}"#,
None,
&[],
true,
true,
)
.await
.unwrap();
assert_eq!(
result.value.unwrap(),
serde_json::json!({
"asyncOrder": ["fast", "slow"],
"inOrder": ["slow", "fast"],
"pending": false,
})
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_response_array_buffer_preserves_typed_array_view() {
let mut rt = setup_runtime("<html><body></body></html>");
+211 -19
View File
@@ -1654,6 +1654,12 @@ struct IfcRegistry {
/// nodes real geometry; `apply_float_continuations` uses it to narrow the
/// later intersecting bands before the final layout.
float_continuations: Vec<FloatContinuation>,
/// Ordinary blocks participating in a native float band must keep a
/// distinct inner line box. Collapsing the block and all of its text into
/// one measured leaf gives Taffy no descendant at which the shared BFC
/// float context can shorten the line while retaining the block's full
/// border-box width.
float_aware_blocks: HashSet<NodeId>,
/// CSS multi-column containers built as a row of anonymous block
/// fragmentainers. All in-flow boxes start in the first fragmentainer so
/// the preliminary layout measures them at the final column width; the
@@ -5490,12 +5496,6 @@ fn layout_dom_once(
crate::Dimension::Px(w) => w,
_ => max_c,
};
let used_outer = match width_style {
// A definite table width is not clamped to its
// containing block. Like other fixed boxes it may
// overflow, while min-content can still make it wider.
crate::Dimension::Px(_) => preferred_outer.max(min_c),
_ => {
let available_outer = available_widths
.get(&tnode)
.copied()
@@ -5508,10 +5508,14 @@ fn layout_dom_once(
)
})
.unwrap_or(initial_cb_width);
preferred_outer.max(min_c).min(available_outer.max(min_c))
}
let mut used_outer = match width_style {
// A definite table width is not clamped to its
// containing block. Like other fixed boxes it may
// overflow, while min-content can still make it wider.
crate::Dimension::Px(_) => preferred_outer.max(min_c),
_ => preferred_outer.max(min_c).min(available_outer.max(min_c)),
};
let used_declaration =
let mut used_declaration =
if table_style.box_sizing == crate::BoxSizing::ContentBox {
(used_outer - inline_outer_edges).max(0.0)
} else {
@@ -5627,8 +5631,6 @@ fn layout_dom_once(
let (horizontal_spacing, _) = table_spacing(table_style);
let interior_spacing =
horizontal_spacing * ncols.saturating_sub(1) as f32;
let target =
(used_outer - inline_outer_edges - interior_spacing).max(0.0);
// A specified length on a cell/column is a preferred
// contribution, not a min-content floor. Preserve the
// content minimum and raise only the preferred width so
@@ -5655,6 +5657,23 @@ fn layout_dom_once(
col_max[j] = width.max(col_min[j]);
}
}
if !matches!(width_style, crate::Dimension::Px(_)) {
let percentage_floor =
auto_table_percentage_intrinsic_floor(&col_min, &percentages);
let percentage_outer =
percentage_floor + inline_outer_edges + interior_spacing;
used_outer = used_outer
.max(percentage_outer)
.min(available_outer.max(min_c));
used_declaration =
if table_style.box_sizing == crate::BoxSizing::ContentBox {
(used_outer - inline_outer_edges).max(0.0)
} else {
used_outer
};
}
let target =
(used_outer - inline_outer_edges - interior_spacing).max(0.0);
let widths = distribute_auto_table_columns(
target,
&col_min,
@@ -8802,7 +8821,11 @@ fn build_in_flow_pseudo(
}
let children = content
.filter(|text| !text.is_empty())
// CSS table fixup discards whitespace-only text between table
// structures. Clearfixes conventionally use `content:" "`, so
// retaining that text as a measured leaf gives the otherwise-empty
// generated table a spurious space glyph of intrinsic width.
.filter(|text| !text.is_empty() && !(pseudo.is_table_box && text.trim().is_empty()))
.map(|text| build_pseudo_content(host, text, pseudo, taffy_tree, words))
.unwrap_or_default();
let mut taffy_style = to_taffy_style(pseudo);
@@ -9350,6 +9373,48 @@ fn table_inline_outer_edges(style: &crate::LayoutStyle) -> f32 {
+ spacing * 2.0
}
/// Minimum track width implied by percentage columns in an auto-width table.
///
/// Percentage table-cell widths participate in the table's intrinsic width.
/// For example, if a 50% column has a 100px minimum next to 100px of auto
/// columns, the table needs 200px before both constraints can hold. When the
/// constraints cannot all fit (a 100% cell plus any non-empty neighbour), the
/// intrinsic requirement is unbounded and the used width is capped by the
/// available containing-block width. This is the behavior Bootstrap input
/// groups rely on to make an otherwise auto-width `display:table` fill their
/// form while reserving intrinsic space for narrow addon/button cells.
fn auto_table_percentage_intrinsic_floor(minimums: &[f32], percentages: &[Option<f32>]) -> f32 {
if minimums.len() != percentages.len() || minimums.is_empty() {
return 0.0;
}
let mut remaining = 1.0f32;
let mut auto_minimum = 0.0f32;
let mut required = 0.0f32;
for (minimum, percentage) in minimums.iter().zip(percentages) {
let minimum = minimum.max(0.0);
let Some(percentage) = percentage else {
auto_minimum += minimum;
continue;
};
let effective = percentage.max(0.0).min(remaining);
remaining = (remaining - effective).max(0.0);
if minimum > 0.0 {
if effective <= f32::EPSILON {
return f32::INFINITY;
}
required = required.max(minimum / effective);
}
}
if auto_minimum > 0.0 {
if remaining <= f32::EPSILON {
return f32::INFINITY;
}
required = required.max(auto_minimum / remaining);
}
required
}
/// Resolve auto-table column constraints into final track widths.
///
/// Gecko's auto-table strategy uses four monotonic guesses: all columns at
@@ -10719,7 +10784,7 @@ fn build(
let is_closed_html_details = _name.ns.as_ref() == "http://www.w3.org/1999/xhtml"
&& _name.local.as_ref() == "details"
&& node.get_attribute("open").is_none();
if !is_closed_html_details {
if !is_closed_html_details && !ifc_items.float_aware_blocks.contains(&id) {
if let Some(item) = engine.try_build(tree, id, styles) {
if style.display == crate::Display::Block && style.width == crate::Dimension::Auto {
// A pure-text block is still a fill-available block. Its shaped
@@ -11976,10 +12041,10 @@ fn is_structural_native_float_pseudo(style: &crate::LayoutStyle) -> bool {
}
/// Native taffy floats are currently sound for a deliberately small structural
/// subset: a flat block band made only of definite-width direct floats plus
/// empty generated/direct clearance boxes. Text flow, transparent wrappers,
/// shrink-to-fit floats, independent formatting contexts, and tables retain
/// the synthetic float-zone path.
/// subset: a flat block band made from boxed direct floats, boxed block flow,
/// and empty generated/direct clearance boxes. Text nodes directly in the
/// parent, transparent wrappers, independent formatting contexts, and tables
/// retain the synthetic float-zone path.
fn can_use_native_float_band(
tree: &DomTree,
parent_style: &crate::LayoutStyle,
@@ -12005,6 +12070,7 @@ fn can_use_native_float_band(
let mut has_right = false;
let mut saw_float = false;
let mut saw_clear_after_floats = false;
let mut saw_flow_after_floats = false;
if let Some(before) = parent_style.before_pseudo.as_deref() {
if !is_structural_native_float_pseudo(before) {
@@ -12031,12 +12097,13 @@ fn can_use_native_float_band(
if let Some(side) = style.float {
if saw_clear_after_floats
|| saw_flow_after_floats
|| style.display_contents
|| style.is_table_box
|| matches!(style.position, Some(taffy::Position::Absolute))
|| !matches!(
style.width,
crate::Dimension::Px(_) | crate::Dimension::Percent(_)
crate::Dimension::Auto | crate::Dimension::Px(_) | crate::Dimension::Percent(_)
)
|| style.size_expressions[0].is_some()
|| has_deferred_or_auto_margin(style)
@@ -12055,6 +12122,22 @@ fn can_use_native_float_band(
has_left,
has_right,
);
} else if saw_float
&& style.display == crate::Display::Block
&& !style.display_contents
&& !style.is_inline_block
&& !style.is_table_box
&& !style.internal_flex_container
&& !matches!(style.position, Some(taffy::Position::Absolute))
{
// Gecko keeps an ordinary block's border box at the BFC's full
// inline size and narrows only its descendant line boxes. A block
// that establishes its own BFC (overflow/flow-root) instead moves
// as one float-avoiding box. Taffy's native block float context
// implements both branches; the old synthetic flex row could
// represent neither distinction because it shrank every sibling's
// outer box beside the float.
saw_flow_after_floats = true;
} else {
return false;
}
@@ -12075,7 +12158,7 @@ fn can_use_native_float_band(
// distinct taffy-side representation, so they are not an escape signal.
let parent_is_native_bfc =
parent_style.overflow_scroll_container && !parent_style.overflow_propagated_to_viewport;
saw_float && (saw_clear_after_floats || parent_is_native_bfc)
saw_float && (saw_flow_after_floats || saw_clear_after_floats || parent_is_native_bfc)
}
fn set_native_float_clear(
@@ -12138,6 +12221,12 @@ fn build_children_with_native_float_band(
let Some(style) = styles.get(&id) else {
continue;
};
if style.float.is_none()
&& style.display == crate::Display::Block
&& !establishes_block_formatting_context(style)
{
ifc_items.float_aware_blocks.insert(id);
}
for node in build_any(
tree, id, taffy_tree, id_map, words, engine, ifc_items, styles,
) {
@@ -13634,6 +13723,62 @@ mod tests {
);
}
#[test]
fn single_float_keeps_normal_block_outer_width_and_shortens_its_line_band() {
// Chromium 145 geometry for a Bootstrap-style single header float,
// an ordinary full-width collapse block, and an overflow BFC below it.
let tree = parse_html(
r#"<style>
*{box-sizing:border-box} html,body{margin:0}
#parent{width:780px}
#parent::before,#parent::after{display:table;content:" "}
#parent::after{clear:both}
#float{float:left;height:100px}
#float::after{display:table;clear:both;content:" "}
#brand{float:left;width:250px;height:100px}
#flow{height:50px}
#line{display:inline-block;width:400px;height:20px}
#bfc{overflow:hidden;height:30px}
</style>
<div id="parent">
<div id="float"><div id="brand"></div></div>
<div id="flow"><span id="line"></span></div>
<div id="bfc"></div>
</div>"#,
);
let laid = layout_dom(&tree, (900.0, 300.0));
let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()];
let parent = rect("parent");
let float = rect("float");
let flow = rect("flow");
let line = rect("line");
let bfc = rect("bfc");
assert_eq!(
(parent.x, parent.y, parent.width, parent.height),
(0.0, 0.0, 780.0, 100.0)
);
assert_eq!(
(float.x, float.y, float.width, float.height),
(0.0, 0.0, 250.0, 100.0)
);
assert_eq!(
(flow.x, flow.y, flow.width, flow.height),
(0.0, 0.0, 780.0, 50.0),
"a normal block border box spans beneath the float"
);
assert_eq!(
(line.x, line.y, line.width, line.height),
(250.0, 0.0, 400.0, 20.0),
"inline content starts at the float band's available edge"
);
assert_eq!(
(bfc.x, bfc.y, bfc.width, bfc.height),
(250.0, 50.0, 530.0, 30.0),
"a float-avoiding BFC moves and shrinks as one box"
);
}
#[test]
fn ineligible_nearest_inline_container_does_not_fall_through() {
let tree = parse_html(
@@ -16101,6 +16246,36 @@ mod tests {
}
}
#[test]
fn auto_width_authored_table_honors_percentage_cell_intrinsic_constraints() {
let tree = parse_html(
r#"<style>
* { box-sizing:border-box }
html,body { margin:0 }
#host { width:600px }
#group { display:table }
#field,#button { display:table-cell; height:34px }
#field { width:100% }
#button { width:1%; min-width:100px }
</style>
<div id="host"><div id="group"><div id="field"></div><div id="button"></div></div></div>"#,
);
let laid = layout_dom(&tree, (800.0, 200.0));
let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()];
let group = rect("group");
let field = rect("field");
let button = rect("button");
// Chromium 145: group=600, field=500, button=100. An auto-width
// table cannot shrink-wrap to 100px here: the 100% field and the
// non-empty 1% neighbor make their percentage constraints fill the
// definite available width.
assert!((group.width - 600.0).abs() < 0.1, "{group:?}");
assert!((field.width - 500.0).abs() < 0.1, "{field:?}");
assert!((button.width - 100.0).abs() < 0.1, "{button:?}");
assert!((button.x - (field.x + field.width)).abs() < 0.1);
}
#[test]
fn auto_table_percent_guess_reserves_intrinsic_neighbor_columns() {
let widths = distribute_auto_table_columns(
@@ -16115,6 +16290,23 @@ mod tests {
assert!((widths[2] - 70.0).abs() < 0.01, "{widths:?}");
}
#[test]
fn auto_table_percentage_intrinsic_floor_matches_chromium_cases() {
let percentages = [Some(0.1), None];
assert!(
(auto_table_percentage_intrinsic_floor(&[16.0, 144.0], &percentages) - 160.0).abs()
< 0.01
);
let percentages = [Some(0.5), None];
assert!(
(auto_table_percentage_intrinsic_floor(&[16.0, 106.71875], &percentages) - 213.4375)
.abs()
< 0.01
);
let percentages = [Some(0.9), None];
assert!(auto_table_percentage_intrinsic_floor(&[16.0, 106.71875], &percentages) > 1000.0);
}
#[test]
fn white_space_inherits_into_a_block_descendant_inline_context() {
let tree = parse_html(
+43
View File
@@ -689,6 +689,43 @@ pub enum BackgroundGradientLayer {
},
}
/// Parsed ending-shape geometry for one CSS radial gradient.
///
/// This is kept alongside `BackgroundGradientLayer` rather than adding fields
/// to its public `Radial` variant, preserving the existing construction API for
/// embedders while allowing authored circle/ellipse sizing to survive to paint.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct RadialGradientGeometry {
pub shape: RadialGradientShape,
pub size: RadialGradientSize,
}
impl Default for RadialGradientGeometry {
fn default() -> Self {
Self {
shape: RadialGradientShape::Ellipse,
size: RadialGradientSize::FarthestCorner,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RadialGradientShape {
Circle,
Ellipse,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum RadialGradientSize {
ClosestSide,
ClosestCorner,
FarthestSide,
FarthestCorner,
/// Horizontal and vertical radii. Percentages resolve against the
/// corresponding axis of the gradient box, per CSS Images.
Explicit(Dimension, Dimension),
}
/// One computed CSS counter operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CounterDirective {
@@ -887,6 +924,9 @@ pub struct LayoutStyle {
/// and color stops. It is painted below the first linear layer, matching
/// the common `linear-gradient(...), radial-gradient(...)` hero pattern.
pub background_radial_gradient: Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
/// Geometry paired with `background_radial_gradient`. The legacy public
/// tuple above remains unchanged for API compatibility.
pub(crate) background_radial_gradient_geometry: Option<RadialGradientGeometry>,
/// `conic-gradient(...)` background. The angle is the CSS `from` angle,
/// the center is a fraction of the border box, and stops are normalized
/// during paint. Conic gradients commonly provide the color source for a
@@ -895,6 +935,9 @@ pub struct LayoutStyle {
/// Every parsed gradient in authored background-layer order. The legacy
/// single-kind fields above remain populated for mask/text fast paths.
pub background_gradient_layers: Vec<BackgroundGradientLayer>,
/// One entry per `background_gradient_layers` item. Radial entries carry
/// their authored ending shape; non-radial entries are `None`.
pub(crate) background_gradient_layer_radial_geometries: Vec<Option<RadialGradientGeometry>>,
/// The first `url(...)` reference from `background`/`background-image`
/// (gradients and repeat keywords in the same shorthand are ignored: we
/// paint the referenced image, not the gradient layer).
+355 -27
View File
@@ -2486,6 +2486,43 @@ fn stacking_z_index(
None
}
/// Whether this box participates in the float painting band of its current
/// stacking context. A retained `float` declaration has no effect on an
/// absolutely positioned box or on a flex/grid item, matching layout's
/// blockification rules.
fn is_effective_float(
tree: &DomTree,
laid: &crate::DomLayout,
id: obscura_dom::tree::NodeId,
) -> bool {
let Some(style) = laid.styles.get(&id) else {
return false;
};
if style.float.is_none()
|| style.display == crate::Display::None
|| style.display_contents
|| matches!(style.position, Some(taffy::Position::Absolute))
{
return false;
}
let mut parent = tree.get_node(id).and_then(|node| node.parent);
while let Some(parent_id) = parent {
let Some(parent_style) = laid.styles.get(&parent_id) else {
parent = tree.get_node(parent_id).and_then(|node| node.parent);
continue;
};
if parent_style.display_contents {
parent = tree.get_node(parent_id).and_then(|node| node.parent);
continue;
}
return parent_style.display != crate::Display::Grid
&& !(parent_style.display == crate::Display::Flex
&& !parent_style.internal_flex_container);
}
true
}
fn has_authored_transform(style: &crate::LayoutStyle) -> bool {
!style.transform_ops.is_empty()
|| style.individual_translate.is_some()
@@ -2618,8 +2655,13 @@ fn paint_laid_dom_scrolled(
// zero, so skip the per-node ancestor walk entirely and keep the paint
// path free of any added cost.
// Paint order: tree order for the normal flow (later elements paint over
// earlier ones), except that a positioned element or a flex/grid item
// Paint order follows CSS2's stacking bands: in-flow block backgrounds
// and borders first, then non-positioned floats, then inline/text content
// (painted by the later text pass). A float is an atomic paint unit in
// this band, so its background, replaced content, and descendants cannot
// be covered by a later full-width normal block background.
//
// A positioned element or a flex/grid item
// with a non-auto z-index lifts its whole subtree into an atomic stacking
// unit: negative layers paint under the normal flow, non-negative ones
// above it, each sorted by z-index ascending (stable, so equal z keeps
@@ -2628,6 +2670,7 @@ fn paint_laid_dom_scrolled(
// leaking into different global paint phases.
let mut neg_layers: Vec<(i32, Vec<obscura_dom::tree::NodeId>)> = Vec::new();
let mut pos_layers: Vec<(i32, Vec<obscura_dom::tree::NodeId>)> = Vec::new();
let mut float_layers: Vec<obscura_dom::tree::NodeId> = Vec::new();
let mut normal: Vec<obscura_dom::tree::NodeId> = Vec::new();
let mut consumed: std::collections::HashSet<obscura_dom::tree::NodeId> =
std::collections::HashSet::new();
@@ -2648,6 +2691,7 @@ fn paint_laid_dom_scrolled(
let z = (suppress_stacking_for != Some(nid))
.then(|| stacking_z_index(tree, laid, nid))
.flatten();
let is_float_root = paint_root != Some(nid) && is_effective_float(tree, laid, nid);
if is_opacity_root || is_transform_root {
let mut sub = vec![nid];
sub.extend(tree.descendants(nid));
@@ -2659,6 +2703,7 @@ fn paint_laid_dom_scrolled(
match z {
Some(z) if z < 0 => neg_layers.push((z, vec![nid])),
Some(z) => pos_layers.push((z, vec![nid])),
None if is_float_root => float_layers.push(nid),
None => normal.push(nid),
}
} else if let Some(z) = z {
@@ -2672,6 +2717,10 @@ fn paint_laid_dom_scrolled(
} else {
pos_layers.push((z, vec![nid]));
}
} else if is_float_root {
consumed.insert(nid);
consumed.extend(tree.descendants(nid));
float_layers.push(nid);
} else {
normal.push(nid);
}
@@ -2682,6 +2731,7 @@ fn paint_laid_dom_scrolled(
.into_iter()
.flat_map(|(_, sub)| sub)
.chain(normal)
.chain(float_layers)
.chain(pos_layers.into_iter().flat_map(|(_, sub)| sub))
.collect();
@@ -2778,6 +2828,39 @@ fn paint_laid_dom_scrolled(
)?;
continue;
}
if paint_root != Some(nid) && is_effective_float(tree, laid, nid) {
opacity_subtree_skip.insert(nid);
opacity_subtree_skip.extend(tree.descendants(nid));
// CSS paints each float as an atomic unit in the float band. The
// recursive display-list build preserves the float's own stacking,
// opacity, transforms, generated boxes, replaced content, and
// inline text while keeping outer in-flow inline content above it.
pixmap = paint_laid_dom_scrolled(
tree,
viewport,
base_url,
scroll,
resolved_scroll,
pixmap,
image_cache,
selected_images,
svg_fonts,
content_size,
viewport_fixed,
sticky,
laid,
Some(nid),
suppress_opacity_for,
suppress_stacking_for,
suppress_transform_for,
clip_scope_root,
surface_extent,
surface_offset,
raster_scale,
print_economy,
)?;
continue;
}
let node = match tree.get_node(nid) {
Some(n) => n,
None => continue,
@@ -3238,6 +3321,10 @@ fn paint_laid_dom_scrolled(
&background.origin_rect,
*center,
stops,
style.background_radial_gradient_geometry,
style.font_size.unwrap_or(16.0),
root_font_size,
viewport,
background_mask,
raster_scale,
);
@@ -3284,6 +3371,10 @@ fn paint_laid_dom_scrolled(
radius,
fill,
style.background_radial_gradient.as_ref(),
style.background_radial_gradient_geometry,
style.font_size.unwrap_or(16.0),
root_font_size,
viewport,
style.background_gradient.as_ref(),
style.background_conic_gradient.as_ref(),
style.mask_size,
@@ -3990,6 +4081,10 @@ fn paint_inline_fragment_decorations(
&background_origin,
*center,
stops,
fragment_style.background_radial_gradient_geometry,
fragment_style.font_size.unwrap_or(16.0),
root_font_size,
viewport,
background_mask.as_ref(),
raster_scale,
);
@@ -4035,6 +4130,10 @@ fn paint_inline_fragment_decorations(
radius,
fill,
fragment_style.background_radial_gradient.as_ref(),
fragment_style.background_radial_gradient_geometry,
fragment_style.font_size.unwrap_or(16.0),
root_font_size,
viewport,
fragment_style.background_gradient.as_ref(),
fragment_style.background_conic_gradient.as_ref(),
fragment_style.mask_size,
@@ -6383,6 +6482,10 @@ fn paint_radial_gradient(
rect: &crate::Rect,
center: (f32, f32),
stops: &[([u8; 4], Option<f32>)],
geometry: Option<crate::RadialGradientGeometry>,
em: f32,
root_font_size: f32,
viewport: (f32, f32),
clip: Option<&tiny_skia::Mask>,
raster_scale: f32,
) {
@@ -6393,14 +6496,11 @@ fn paint_radial_gradient(
rect.x + rect.width * center.0,
rect.y + rect.height * center.1,
);
let radius = [
(rect.x - center.x).hypot(rect.y - center.y),
(rect.x + rect.width - center.x).hypot(rect.y - center.y),
(rect.x - center.x).hypot(rect.y + rect.height - center.y),
(rect.x + rect.width - center.x).hypot(rect.y + rect.height - center.y),
]
.into_iter()
.fold(0.0, f32::max);
let Some((radius_x, radius_y)) =
resolve_radial_gradient_radii(rect, center, geometry, em, root_font_size, viewport)
else {
return;
};
let normalized = normalized_stops(stops);
let gradient_stops = normalized
.into_iter()
@@ -6411,14 +6511,26 @@ fn paint_radial_gradient(
)
})
.collect();
// tiny-skia's native radial shader is circular. Keep the established CSS
// coordinate-space shader and transform that circle around its center into
// the authored ellipse. The paint transform later handles device scale.
let ellipse_scale = radius_y / radius_x;
let gradient_transform = Transform::from_row(
1.0,
0.0,
0.0,
ellipse_scale,
0.0,
center.y * (1.0 - ellipse_scale),
);
if let Some(shader) = RadialGradient::new(
center,
0.0,
center,
radius,
radius_x,
gradient_stops,
SpreadMode::Pad,
Transform::identity(),
gradient_transform,
) {
let mut paint = Paint::default();
paint.shader = shader;
@@ -6433,6 +6545,81 @@ fn paint_radial_gradient(
}
}
fn resolve_radial_gradient_radii(
rect: &crate::Rect,
center: Point,
geometry: Option<crate::RadialGradientGeometry>,
em: f32,
root_font_size: f32,
viewport: (f32, f32),
) -> Option<(f32, f32)> {
use crate::{RadialGradientShape as Shape, RadialGradientSize as Size};
let left = (center.x - rect.x).abs();
let right = (rect.x + rect.width - center.x).abs();
let top = (center.y - rect.y).abs();
let bottom = (rect.y + rect.height - center.y).abs();
// Programmatically constructed legacy `BackgroundGradientLayer::Radial`
// values have no sidecar geometry. Preserve their former circular
// farthest-corner behavior exactly.
let Some(geometry) = geometry else {
let radius = [
left.hypot(top),
right.hypot(top),
left.hypot(bottom),
right.hypot(bottom),
]
.into_iter()
.fold(0.0, f32::max);
return (radius > f32::EPSILON).then_some((radius, radius));
};
let (mut radius_x, mut radius_y) = match geometry.size {
Size::ClosestSide => (left.min(right), top.min(bottom)),
Size::FarthestSide => (left.max(right), top.max(bottom)),
Size::ClosestCorner => {
let sqrt_two = 2.0f32.sqrt();
(left.min(right) * sqrt_two, top.min(bottom) * sqrt_two)
}
Size::FarthestCorner => {
let sqrt_two = 2.0f32.sqrt();
(left.max(right) * sqrt_two, top.max(bottom) * sqrt_two)
}
Size::Explicit(x, y) => (
resolve_radial_radius(x, rect.width, em, root_font_size, viewport)?,
resolve_radial_radius(y, rect.height, em, root_font_size, viewport)?,
),
};
if geometry.shape == Shape::Circle && !matches!(geometry.size, Size::Explicit(..)) {
let radius = match geometry.size {
Size::ClosestSide => radius_x.min(radius_y),
Size::FarthestSide => radius_x.max(radius_y),
Size::ClosestCorner => left.min(right).hypot(top.min(bottom)),
Size::FarthestCorner => left.max(right).hypot(top.max(bottom)),
Size::Explicit(..) => unreachable!(),
};
radius_x = radius;
radius_y = radius;
}
(radius_x > f32::EPSILON && radius_y > f32::EPSILON).then_some((radius_x, radius_y))
}
fn resolve_radial_radius(
radius: crate::Dimension,
percentage_basis: f32,
em: f32,
root_font_size: f32,
viewport: (f32, f32),
) -> Option<f32> {
match radius.resolve(em, root_font_size, viewport.0 / 100.0, viewport.1 / 100.0) {
crate::Dimension::Px(value) => Some(value),
crate::Dimension::Percent(value) => Some(value * percentage_basis),
_ => None,
}
.filter(|value| value.is_finite() && *value >= 0.0)
}
fn paint_background_gradient_layers(
pixmap: &mut Pixmap,
path: &tiny_skia::Path,
@@ -6484,6 +6671,7 @@ fn paint_background_gradient_layers(
&tile_rect,
crate::ResolvedBorderRadii::default(),
layers,
&style.background_gradient_layer_radial_geometries,
em,
root_font_size,
viewport,
@@ -6578,6 +6766,7 @@ fn paint_background_gradient_layers(
clip_rect,
clip_radii,
layers,
&style.background_gradient_layer_radial_geometries,
em,
root_font_size,
viewport,
@@ -6593,6 +6782,7 @@ fn paint_gradient_layer_stack(
clip_rect: &crate::Rect,
clip_radii: crate::ResolvedBorderRadii,
layers: &[crate::BackgroundGradientLayer],
radial_geometries: &[Option<crate::RadialGradientGeometry>],
em: f32,
root_font_size: f32,
viewport: (f32, f32),
@@ -6601,7 +6791,7 @@ fn paint_gradient_layer_stack(
) {
// CSS lists the topmost background first. Paint back-to-front so every
// translucent layer composites over the layers authored after it.
for layer in layers.iter().rev() {
for (index, layer) in layers.iter().enumerate().rev() {
match layer {
crate::BackgroundGradientLayer::Linear {
angle,
@@ -6631,6 +6821,10 @@ fn paint_gradient_layer_stack(
sampling_rect,
*center,
stops,
radial_geometries.get(index).copied().flatten(),
em,
root_font_size,
viewport,
clip,
raster_scale,
);
@@ -6876,24 +7070,26 @@ fn radial_color_at(
rect: &crate::Rect,
center: (f32, f32),
stops: &[(f32, [u8; 4])],
geometry: Option<crate::RadialGradientGeometry>,
em: f32,
root_font_size: f32,
viewport: (f32, f32),
x: f32,
y: f32,
) -> [u8; 4] {
let center_x = rect.x + rect.width * center.0;
let center_y = rect.y + rect.height * center.1;
let radius = [
(rect.x - center_x).hypot(rect.y - center_y),
(rect.x + rect.width - center_x).hypot(rect.y - center_y),
(rect.x - center_x).hypot(rect.y + rect.height - center_y),
(rect.x + rect.width - center_x).hypot(rect.y + rect.height - center_y),
]
.into_iter()
.fold(0.0, f32::max);
let position = if radius <= f32::EPSILON {
0.0
} else {
(x - center_x).hypot(y - center_y) / radius
};
let radii = resolve_radial_gradient_radii(
rect,
Point::from_xy(center_x, center_y),
geometry,
em,
root_font_size,
viewport,
);
let position = radii.map_or(0.0, |(radius_x, radius_y)| {
(((x - center_x) / radius_x).powi(2) + ((y - center_y) / radius_y).powi(2)).sqrt()
});
sample_normalized_stops(stops, position)
}
@@ -7171,6 +7367,10 @@ fn paint_in_flow_generated_box(
&background.origin_rect,
*center,
stops,
style.background_radial_gradient_geometry,
style.font_size.unwrap_or(16.0),
root_font_size,
viewport,
background_mask.as_ref(),
raster_scale,
);
@@ -7215,6 +7415,10 @@ fn paint_in_flow_generated_box(
radius,
fill,
style.background_radial_gradient.as_ref(),
style.background_radial_gradient_geometry,
style.font_size.unwrap_or(16.0),
root_font_size,
viewport,
style.background_gradient.as_ref(),
style.background_conic_gradient.as_ref(),
style.mask_size,
@@ -7405,6 +7609,10 @@ fn paint_positioned_pseudo(
&background.origin_rect,
*center,
stops,
style.background_radial_gradient_geometry,
em,
root_font_size,
viewport,
background_mask.as_ref(),
raster_scale,
);
@@ -7449,6 +7657,10 @@ fn paint_positioned_pseudo(
radius,
fill,
style.background_radial_gradient.as_ref(),
style.background_radial_gradient_geometry,
em,
root_font_size,
viewport,
style.background_gradient.as_ref(),
style.background_conic_gradient.as_ref(),
style.mask_size,
@@ -9202,6 +9414,10 @@ fn paint_mask(
border_radius: crate::ResolvedBorderRadii,
fill: [u8; 4],
radial_gradient: Option<&((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
radial_geometry: Option<crate::RadialGradientGeometry>,
em: f32,
root_font_size: f32,
viewport: (f32, f32),
linear_gradient: Option<&(f32, Vec<([u8; 4], Option<f32>)>)>,
conic_gradient: Option<&(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)>,
mask_size: Option<(f32, f32)>,
@@ -9270,7 +9486,17 @@ fn paint_mask(
} else if let (Some((center, _)), Some(stops)) =
(radial_gradient, normalized_radial.as_deref())
{
radial_color_at(rect, *center, stops, sample_x, sample_y)
radial_color_at(
rect,
*center,
stops,
radial_geometry,
em,
root_font_size,
viewport,
sample_x,
sample_y,
)
} else {
fill
};
@@ -10105,6 +10331,54 @@ mod tests {
assert_eq!(outside.blue(), 255);
}
#[test]
fn float_paints_above_normal_block_backgrounds_and_below_later_flow() {
// Chromium 145: ordinary block border boxes remain full-width beneath
// the float, but their backgrounds are in the lower block-background
// paint band. The right float therefore stays visible through its
// 80px height; the following block becomes visible across the full
// width once it starts below the float.
let tree = parse_html(
r#"<style>
html,body{margin:0}
#bfc{display:flow-root;width:200px}
#float{float:right;width:50px;height:80px;background:#1971c2}
#lead{height:50px;background:#087f5b}
#heading{height:10px;background:#e8590c}
#beside{height:20px;background:#7048e8}
#after{height:20px;background:#a61e4d}
</style>
<main id="bfc">
<aside id="float"></aside>
<div id="lead"></div>
<div id="heading"></div>
<div id="beside"></div>
<div id="after"></div>
</main>"#,
);
let pixmap = paint_dom(&tree, (200.0, 120.0), None).expect("float paint");
let rgb = |x, y| {
let pixel = pixmap.pixel(x, y).expect("sample pixel");
(pixel.red(), pixel.green(), pixel.blue())
};
assert_eq!(rgb(25, 25), (8, 127, 91), "left side keeps lead background");
assert_eq!(rgb(25, 55), (232, 89, 12), "left side keeps heading background");
assert_eq!(rgb(25, 70), (112, 72, 232), "left side keeps beside background");
for y in [25, 55, 70] {
assert_eq!(
rgb(175, y),
(25, 113, 194),
"float must overlay the full-width block background at y={y}"
);
}
assert_eq!(
rgb(175, 90),
(166, 30, 77),
"normal flow below the float paints across the full width"
);
}
/// Chromium 150 oracle for CSS Backgrounds box geometry. Transparent
/// borders make each clip edge directly observable, asymmetric insets
/// distinguish authored gradient coordinates from the visible clip, and
@@ -13206,6 +13480,60 @@ mod tests {
);
}
#[test]
fn explicit_radial_ellipse_matches_chromium_axis_samples() {
let tree = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div style="width:200px;height:100px;
background:radial-gradient(50% 25% at 50% 50%,#fff 0%,#000 100%)">
</div>
</body></html>"#,
);
let pixmap = paint_dom(&tree, (200.0, 100.0), None).expect("pixmap");
// Chromium 144 at DPR 1 yields R values 249, 126, 2, 127, and 5 at
// these pixel centers. Allow a small raster-backend interpolation
// tolerance while requiring both authored radii to control geometry.
for ((x, y), chromium) in [
((100, 50), 249i16),
((150, 50), 126),
((199, 50), 2),
((100, 62), 127),
((100, 74), 5),
] {
let pixel = pixmap.pixel(x, y).expect("sample pixel");
let actual = pixel.red() as i16;
assert!(
(actual - chromium).abs() <= 5,
"ellipse sample ({x},{y}) was {actual}, Chromium was {chromium}: {pixel:?}"
);
}
}
#[test]
fn radial_extent_keywords_keep_circle_and_ellipse_geometry_distinct() {
let tree = parse_html(
r#"<html style="margin:0"><body style="margin:0;display:flex">
<div style="width:200px;height:100px;
background:radial-gradient(circle farthest-side at 25% 50%,#fff,#000)">
</div>
<div style="width:200px;height:100px;
background:radial-gradient(ellipse farthest-side at 25% 50%,#fff,#000)">
</div>
</body></html>"#,
);
let pixmap = paint_dom(&tree, (400.0, 100.0), None).expect("pixmap");
let circle_bottom = pixmap.pixel(50, 99).expect("circle sample").red();
let ellipse_bottom = pixmap.pixel(250, 99).expect("ellipse sample").red();
assert!(
circle_bottom > 150,
"farthest-side circle radius is 150px, so its bottom remains light: {circle_bottom}"
);
assert!(
ellipse_bottom < 10,
"farthest-side ellipse vertical radius is 50px: {ellipse_bottom}"
);
}
#[test]
fn effectful_region_capture_keeps_the_proven_bounded_resample_fallback() {
let tree = parse_html(
+237 -16
View File
@@ -2596,7 +2596,7 @@ fn supports_conservative_known_value(name: &str, value: &str) -> bool {
"background-image" | "mask-image" | "-webkit-mask-image" => {
lower == "none"
|| parse_url(value).is_some()
|| !parse_background_gradient_layers(value, false).is_empty()
|| !parse_background_gradient_layers(value, false).0.is_empty()
}
"background-repeat" | "mask-repeat" | "-webkit-mask-repeat" => {
parse_image_repeat(value).is_some()
@@ -6728,7 +6728,8 @@ fn token(value: &str) -> Option<&str> {
}
fn set_background_gradients(style: &mut LayoutStyle, value: &str) {
let layers = parse_background_gradient_layers(value, style.color_scheme_dark);
let (layers, radial_geometries) =
parse_background_gradient_layers(value, style.color_scheme_dark);
style.background_gradient = layers.iter().find_map(|layer| match layer {
crate::BackgroundGradientLayer::Linear { angle, stops, .. } => {
Some((*angle, stops.clone()))
@@ -6739,6 +6740,15 @@ fn set_background_gradients(style: &mut LayoutStyle, value: &str) {
crate::BackgroundGradientLayer::Radial { center, stops } => Some((*center, stops.clone())),
_ => None,
});
style.background_radial_gradient_geometry =
layers
.iter()
.zip(&radial_geometries)
.find_map(|(layer, geometry)| {
matches!(layer, crate::BackgroundGradientLayer::Radial { .. })
.then_some(*geometry)
.flatten()
});
style.background_conic_gradient = layers.iter().find_map(|layer| match layer {
crate::BackgroundGradientLayer::Conic {
angle,
@@ -6748,13 +6758,18 @@ fn set_background_gradients(style: &mut LayoutStyle, value: &str) {
_ => None,
});
style.background_gradient_layers = layers;
style.background_gradient_layer_radial_geometries = radial_geometries;
}
fn parse_background_gradient_layers(
value: &str,
dark_scheme: bool,
) -> Vec<crate::BackgroundGradientLayer> {
) -> (
Vec<crate::BackgroundGradientLayer>,
Vec<Option<crate::RadialGradientGeometry>>,
) {
let mut layers = Vec::new();
let mut radial_geometries = Vec::new();
for authored_layer in split_top_level(value, ',') {
if let Some(linear) = parse_linear_gradient(authored_layer, dark_scheme) {
layers.push(crate::BackgroundGradientLayer::Linear {
@@ -6763,8 +6778,13 @@ fn parse_background_gradient_layers(
stop_positions: linear.stop_positions,
repeating: linear.repeating,
});
} else if let Some((center, stops)) = parse_radial_gradient(authored_layer, dark_scheme) {
layers.push(crate::BackgroundGradientLayer::Radial { center, stops });
radial_geometries.push(None);
} else if let Some(radial) = parse_radial_gradient(authored_layer, dark_scheme) {
layers.push(crate::BackgroundGradientLayer::Radial {
center: radial.center,
stops: radial.stops,
});
radial_geometries.push(Some(radial.geometry));
} else if let Some((angle, center, stops)) =
parse_conic_gradient(authored_layer, dark_scheme)
{
@@ -6773,9 +6793,10 @@ fn parse_background_gradient_layers(
center,
stops,
});
radial_geometries.push(None);
}
}
layers
(layers, radial_geometries)
}
struct ParsedLinearGradient {
@@ -6938,10 +6959,13 @@ fn gradient_position_is_valid(value: &str) -> bool {
})
}
fn parse_radial_gradient(
value: &str,
dark_scheme: bool,
) -> Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)> {
struct ParsedRadialGradient {
center: (f32, f32),
stops: Vec<([u8; 4], Option<f32>)>,
geometry: crate::RadialGradientGeometry,
}
fn parse_radial_gradient(value: &str, dark_scheme: bool) -> Option<ParsedRadialGradient> {
let lower = value.to_ascii_lowercase();
let start = lower.find("radial-gradient(")?;
let open = start + "radial-gradient(".len();
@@ -6951,17 +6975,19 @@ fn parse_radial_gradient(
return None;
}
let mut center = (0.5, 0.5);
let mut geometry = crate::RadialGradientGeometry::default();
let mut stop_start = 0;
let prelude = parts[0].trim().to_ascii_lowercase();
if prelude.contains(" at ") || prelude.starts_with("at ") {
let coords = prelude
let (shape, coords) = prelude
.split_once(" at ")
.map(|(_, coords)| coords)
.or_else(|| prelude.strip_prefix("at "))
.unwrap_or_default();
.map(|(shape, coords)| (shape, coords))
.or_else(|| prelude.strip_prefix("at ").map(|coords| ("", coords)))?;
geometry = parse_radial_gradient_geometry(shape)?;
center = parse_gradient_center(coords);
stop_start = 1;
} else if parse_color_for_scheme(parts[0].trim(), dark_scheme).is_none() {
} else if parse_color_for_scheme(split_color_stop(parts[0].trim()).0, dark_scheme).is_none() {
geometry = parse_radial_gradient_geometry(&prelude)?;
stop_start = 1;
}
let mut stops = Vec::new();
@@ -6971,7 +6997,131 @@ fn parse_radial_gradient(
stops.push((color, position));
}
}
(stops.len() >= 2).then_some((center, stops))
(stops.len() >= 2).then_some(ParsedRadialGradient {
center,
stops,
geometry,
})
}
fn parse_radial_gradient_geometry(value: &str) -> Option<crate::RadialGradientGeometry> {
use crate::{
RadialGradientGeometry as Geometry, RadialGradientShape as Shape,
RadialGradientSize as Size,
};
let tokens = value
.split_whitespace()
.map(str::trim)
.filter(|token| !token.is_empty())
.collect::<Vec<_>>();
if tokens.is_empty() {
return Some(Geometry::default());
}
let explicit_shape = tokens.iter().find_map(|token| match *token {
"circle" => Some(Shape::Circle),
"ellipse" => Some(Shape::Ellipse),
_ => None,
});
if tokens
.iter()
.filter(|token| matches!(**token, "circle" | "ellipse"))
.count()
> 1
{
return None;
}
let extent = tokens.iter().find_map(|token| match *token {
"closest-side" | "contain" => Some(Size::ClosestSide),
"closest-corner" => Some(Size::ClosestCorner),
"farthest-side" => Some(Size::FarthestSide),
"farthest-corner" | "cover" => Some(Size::FarthestCorner),
_ => None,
});
if tokens
.iter()
.filter(|token| {
matches!(
**token,
"closest-side"
| "closest-corner"
| "farthest-side"
| "farthest-corner"
| "contain"
| "cover"
)
})
.count()
> 1
{
return None;
}
let dimensions = tokens
.iter()
.filter(|token| {
!matches!(
**token,
"circle"
| "ellipse"
| "closest-side"
| "closest-corner"
| "farthest-side"
| "farthest-corner"
| "contain"
| "cover"
)
})
.map(|token| dimension_value(token))
.collect::<Vec<_>>();
if dimensions
.iter()
.any(|dimension| !radial_radius_is_non_negative(*dimension))
{
return None;
}
if extent.is_some() && !dimensions.is_empty() {
return None;
}
match dimensions.as_slice() {
[] => Some(Geometry {
shape: explicit_shape.unwrap_or(Shape::Ellipse),
size: extent.unwrap_or(Size::FarthestCorner),
}),
[radius] if explicit_shape != Some(Shape::Ellipse) => {
// Circle radii are lengths, never percentages.
if matches!(radius, crate::Dimension::Percent(_)) {
return None;
}
Some(Geometry {
shape: Shape::Circle,
size: Size::Explicit(*radius, *radius),
})
}
[x, y] if explicit_shape != Some(Shape::Circle) => Some(Geometry {
shape: Shape::Ellipse,
size: Size::Explicit(*x, *y),
}),
_ => None,
}
}
fn radial_radius_is_non_negative(value: crate::Dimension) -> bool {
match value {
crate::Dimension::Auto => false,
crate::Dimension::Px(value)
| crate::Dimension::Percent(value)
| crate::Dimension::Em(value)
| crate::Dimension::Ex(value)
| crate::Dimension::Rem(value)
| crate::Dimension::Vw(value)
| crate::Dimension::Vh(value)
| crate::Dimension::Vmin(value)
| crate::Dimension::Vmax(value) => value.is_finite() && value >= 0.0,
}
}
fn parse_gradient_center(value: &str) -> (f32, f32) {
@@ -9321,6 +9471,77 @@ mod tests {
);
}
#[test]
fn radial_gradients_retain_explicit_ellipse_radii_and_extent_shape() {
let explicit = compute_style(
"div",
Some(
"background:radial-gradient(141.53% 114.68% at 87.46% 55.27%,\
#9a7cff 36.75%,#0e0aa200 100%)",
),
);
assert_eq!(
explicit.background_radial_gradient_geometry,
Some(crate::RadialGradientGeometry {
shape: crate::RadialGradientShape::Ellipse,
size: crate::RadialGradientSize::Explicit(
crate::Dimension::Percent(1.4153),
crate::Dimension::Percent(1.1468),
),
})
);
assert_eq!(
explicit
.background_radial_gradient
.as_ref()
.map(|value| value.0),
Some((0.8746, 0.5527))
);
let keyword = compute_style(
"div",
Some("background-image:radial-gradient(circle closest-side at 25% 75%,red,blue)"),
);
assert_eq!(
keyword.background_radial_gradient_geometry,
Some(crate::RadialGradientGeometry {
shape: crate::RadialGradientShape::Circle,
size: crate::RadialGradientSize::ClosestSide,
})
);
let lengths = compute_style(
"div",
Some("background-image:radial-gradient(80px 2em at center,#fff 0%,#000 100%)"),
);
assert_eq!(
lengths.background_radial_gradient_geometry,
Some(crate::RadialGradientGeometry {
shape: crate::RadialGradientShape::Ellipse,
size: crate::RadialGradientSize::Explicit(
crate::Dimension::Px(80.0),
crate::Dimension::Em(2.0),
),
})
);
}
#[test]
fn radial_gradient_rejects_invalid_shape_radius_combinations() {
for value in [
"radial-gradient(circle 50%,red,blue)",
"radial-gradient(ellipse 10px,red,blue)",
"radial-gradient(circle 10px 20px,red,blue)",
"radial-gradient(-10px 20px,red,blue)",
] {
let style = compute_style("div", Some(&format!("background-image:{value}")));
assert!(
style.background_gradient_layers.is_empty(),
"invalid radial geometry must not become a painted layer: {value}"
);
}
}
#[test]
fn repeating_linear_gradient_retains_length_stops_and_repeat_axes() {
let style = compute_style(
+8 -3
View File
@@ -939,6 +939,11 @@ fn float_flow_zone_preserves_blocks_inline_runs_and_clearance() {
#[test]
fn float_exclusion_continues_through_non_bfc_block_wrappers() {
// Chromium keeps each ordinary block's outer box at the containing
// block's full width. Only line boxes inside those blocks query the
// float-reduced available band; backgrounds and borders extend beneath
// the float. This is deliberately distinct from a new BFC, which avoids
// the float as one box.
let tree = parse_html(include_str!(
"../../../render-repros/float-bfc-continuation.html"
));
@@ -959,21 +964,21 @@ fn float_exclusion_continues_through_non_bfc_block_wrappers() {
assert!(
(lead.x - 0.0).abs() < 0.01
&& (lead.y - 0.0).abs() < 0.01
&& (lead.width - 650.0).abs() < 0.01
&& (lead.width - 900.0).abs() < 0.01
&& (lead.height - 250.0).abs() < 0.01,
"lead: {lead:?}"
);
assert!(
(heading.x - 0.0).abs() < 0.01
&& (heading.y - 250.0).abs() < 0.01
&& (heading.width - 650.0).abs() < 0.01
&& (heading.width - 900.0).abs() < 0.01
&& (heading.height - 50.0).abs() < 0.01,
"heading: {heading:?}"
);
assert!(
(beside.x - 0.0).abs() < 0.01
&& (beside.y - 300.0).abs() < 0.01
&& (beside.width - 650.0).abs() < 0.01
&& (beside.width - 900.0).abs() < 0.01
&& (beside.height - 100.0).abs() < 0.01,
"beside: {beside:?}"
);