Support RTL layout and video posters

This commit is contained in:
SGavrl committed 2026-08-04 09:04:45 +00:00
1 parent 68c81f319c
commit 012a7dce2f
9 files changed
+473 -77

No files matched your search

+49 -16
View File
@@ -5116,25 +5116,42 @@ globalThis.HTMLImageElement = HTMLImageElement;
_markNative(HTMLImageElement);
_markNative(HTMLImageElement.prototype.decode);
// Media elements need canPlayType for codec detection fingerprinting.
// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
// Report only capabilities backed by a real decoder. Poster rendering is an
// image operation and does not make any audio/video container playable.
class HTMLMediaElement extends Element {
canPlayType(type) {
if (!type || typeof type !== 'string') return '';
const mime = type.split(';')[0].trim().toLowerCase();
if (mime === 'video/mp4' || mime === 'video/webm' || mime === 'video/ogg') return 'probably';
if (mime === 'video/x-matroska') return 'maybe';
if (mime === 'audio/ogg' || mime === 'audio/webm' || mime === 'audio/wav' ||
mime === 'audio/mpeg') return 'probably';
if (mime === 'audio/mp4' || mime === 'audio/x-m4a' || mime === 'audio/aac') return 'maybe';
return '';
}
static NETWORK_EMPTY = 0;
static NETWORK_IDLE = 1;
static NETWORK_LOADING = 2;
static NETWORK_NO_SOURCE = 3;
static HAVE_NOTHING = 0;
static HAVE_METADATA = 1;
static HAVE_CURRENT_DATA = 2;
static HAVE_FUTURE_DATA = 3;
static HAVE_ENOUGH_DATA = 4;
canPlayType(_type) { return ''; }
load() {}
play() { return Promise.resolve(); }
play() {
return Promise.reject(new DOMException(
"The element has no supported sources.",
"NotSupportedError",
));
}
pause() {}
get NETWORK_EMPTY() { return HTMLMediaElement.NETWORK_EMPTY; }
get NETWORK_IDLE() { return HTMLMediaElement.NETWORK_IDLE; }
get NETWORK_LOADING() { return HTMLMediaElement.NETWORK_LOADING; }
get NETWORK_NO_SOURCE() { return HTMLMediaElement.NETWORK_NO_SOURCE; }
get HAVE_NOTHING() { return HTMLMediaElement.HAVE_NOTHING; }
get HAVE_METADATA() { return HTMLMediaElement.HAVE_METADATA; }
get HAVE_CURRENT_DATA() { return HTMLMediaElement.HAVE_CURRENT_DATA; }
get HAVE_FUTURE_DATA() { return HTMLMediaElement.HAVE_FUTURE_DATA; }
get HAVE_ENOUGH_DATA() { return HTMLMediaElement.HAVE_ENOUGH_DATA; }
get paused() { return true; }
get ended() { return false; }
get readyState() { return 0; }
get networkState() { return HTMLMediaElement.NETWORK_EMPTY; }
get readyState() { return HTMLMediaElement.HAVE_NOTHING; }
get error() { return null; }
get seeking() { return false; }
get currentTime() { return 0; }
set currentTime(v) {}
get duration() { return NaN; }
@@ -5142,8 +5159,14 @@ class HTMLMediaElement extends Element {
set volume(v) {}
get muted() { return false; }
set muted(v) {}
get src() { return this.getAttribute('src') || ''; }
get src() {
const raw = this.getAttribute("src");
if (!raw) return "";
try { return new URL(raw, this.baseURI || globalThis.location?.href || "about:blank").href; }
catch (_error) { return raw; }
}
set src(v) { this.setAttribute('src', v); }
get currentSrc() { return ""; }
get textTracks() {
return TextTrackList.from(
Array.from(this.querySelectorAll("track")).map((element) => element.track)
@@ -5157,7 +5180,17 @@ _markNative(HTMLMediaElement.prototype.canPlayType);
_markNative(HTMLMediaElement.prototype.play);
_markNative(HTMLMediaElement.prototype.load);
_markNative(HTMLMediaElement.prototype.pause);
class HTMLVideoElement extends HTMLMediaElement {}
class HTMLVideoElement extends HTMLMediaElement {
get poster() {
const raw = this.getAttribute("poster");
if (!raw) return "";
try { return new URL(raw, this.baseURI || globalThis.location?.href || "about:blank").href; }
catch (_error) { return raw; }
}
set poster(value) { this.setAttribute("poster", value); }
get videoWidth() { return 0; }
get videoHeight() { return 0; }
}
class HTMLAudioElement extends HTMLMediaElement {}
class HTMLTrackElement extends Element {
static NONE = 0;
+70 -23
View File
@@ -667,9 +667,9 @@ impl ObscuraJsRuntime {
}
/// Return the exact responsive candidates selected for live `<img>`
/// elements without loading them. The browser layer can then fetch them
/// concurrently through the page-owned transport before synchronous layout
/// or paint observes the cache.
/// elements and `<video poster>` resources without loading them. The
/// browser layer can then fetch them concurrently through the page-owned
/// transport before synchronous layout or paint observes the cache.
#[cfg(feature = "render")]
pub fn pending_render_image_urls(&self) -> Vec<(String, crate::ops::ImageRequestProfile)> {
let state = self.state.borrow();
@@ -682,30 +682,37 @@ impl ObscuraJsRuntime {
let Some(node) = dom.get_node(id) else {
continue;
};
if node
.as_element()
.is_none_or(|element| element.local.as_ref() != "img")
{
let Some(element) = node.as_element() else {
continue;
}
let Some((url, _, known, _)) = state.render_resources.cached_image_element_metadata(
dom,
id,
state.viewport,
base_url.as_deref(),
) else {
};
let candidate = match element.local.as_ref() {
"img" => state
.render_resources
.cached_image_element_metadata(dom, id, state.viewport, base_url.as_deref())
.map(|(url, _, known, _)| {
let profile = match node
.get_attribute("crossorigin")
.map(|value| value.trim().to_ascii_lowercase())
.as_deref()
{
Some("use-credentials") => {
crate::ops::ImageRequestProfile::CorsInclude
}
Some(_) => crate::ops::ImageRequestProfile::CorsSameOrigin,
None => crate::ops::ImageRequestProfile::NoCorsInclude,
};
(url, profile, known)
}),
"video" => state
.render_resources
.cached_video_poster_metadata(dom, id, base_url.as_deref())
.map(|(url, profile, known, _)| (url, profile, known)),
_ => None,
};
let Some((url, profile, known)) = candidate else {
continue;
};
if !known && !url.starts_with("data:") {
let profile = match node
.get_attribute("crossorigin")
.map(|value| value.trim().to_ascii_lowercase())
.as_deref()
{
Some("use-credentials") => crate::ops::ImageRequestProfile::CorsInclude,
Some(_) => crate::ops::ImageRequestProfile::CorsSameOrigin,
None => crate::ops::ImageRequestProfile::NoCorsInclude,
};
urls.push((url, profile));
}
}
@@ -12608,6 +12615,46 @@ mod tests {
);
}
#[test]
fn unsupported_media_capabilities_and_readiness_are_honest() {
let mut rt = setup_runtime(
r#"<video id="media" src="https://example.test/movie.mp4"
poster="https://example.test/poster.png"></video>"#,
);
let result = rt
.evaluate(
r#"
const media = document.getElementById("media");
return [
media.canPlayType("video/mp4"),
media.canPlayType('video/webm; codecs="vp9"'),
media.readyState,
media.currentTime,
media.videoWidth,
media.videoHeight,
media.paused,
media.currentSrc,
media.poster,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
"",
"",
0,
0,
0,
0,
true,
"",
"https://example.test/poster.png"
])
);
}
#[test]
fn html_string_scripts_remain_inert_when_connected() {
let mut rt = setup_runtime("<html><head></head><body><div id=target></div></body></html>");
+88 -20
View File
@@ -1916,6 +1916,13 @@ pub(crate) fn resolve_translate(d: crate::Dimension, basis: f32) -> f32 {
/// Apply HTML presentational attributes at their cascade origin: above the UA
/// defaults, but below every author stylesheet and style attribute.
fn apply_presentational_hints(node: &obscura_dom::tree::Node, style: &mut crate::LayoutStyle) {
if let Some(direction) = node.get_attribute("dir") {
style.direction = match direction.trim().to_ascii_lowercase().as_str() {
"ltr" => Some(taffy::Direction::Ltr),
"rtl" => Some(taffy::Direction::Rtl),
_ => style.direction,
};
}
if let Some(color) = node.get_attribute("color") {
crate::style::apply_inline(style, &format!("color: {}", color));
}
@@ -3912,6 +3919,7 @@ fn layout_dom_once(
#[derive(Clone)]
struct Inherited {
display: crate::Display,
direction: taffy::Direction,
display_contents: bool,
is_inline_block: bool,
flow_root: bool,
@@ -3963,6 +3971,7 @@ fn layout_dom_once(
// CSS Display's initial outer/inner value is inline flow.
// The root is blockified after computed values settle.
display: crate::Display::Inline,
direction: taffy::Direction::Ltr,
display_contents: false,
is_inline_block: false,
flow_root: false,
@@ -4063,6 +4072,7 @@ fn layout_dom_once(
vh,
);
inh.display = style.display;
inh.direction = style.direction.unwrap_or(inh.direction);
inh.display_contents = style.display_contents;
inh.is_inline_block = style.is_inline_block;
inh.flow_root = style.flow_root;
@@ -4245,6 +4255,10 @@ fn layout_dom_once(
style.display_inherit = false;
}
inh.display = style.display;
match style.direction {
Some(direction) => inh.direction = direction,
None => style.direction = Some(inh.direction),
}
inh.display_contents = style.display_contents;
inh.is_inline_block = style.is_inline_block;
inh.flow_root = style.flow_root;
@@ -4678,6 +4692,7 @@ fn layout_dom_once(
let host_text_indent = style.text_indent;
let host_invisible = style.effectively_invisible;
let host_display = style.display;
let host_direction = style.direction.unwrap_or(taffy::Direction::Ltr);
let host_display_contents = style.display_contents;
let host_is_inline_block = style.is_inline_block;
let host_flow_root = style.flow_root;
@@ -4702,6 +4717,9 @@ fn layout_dom_once(
0
};
let settle_pseudo = |pseudo: &mut crate::LayoutStyle| {
if pseudo.direction.is_none() {
pseudo.direction = Some(host_direction);
}
if pseudo.grid_auto_columns_inherit {
pseudo.grid_auto_columns = host_grid_auto_columns.clone();
pseudo.grid_calc_expressions[2] = host_grid_auto_column_calcs.clone();
@@ -6986,10 +7004,9 @@ fn fixed_grid_tracks(widths: &[f32]) -> Vec<taffy::GridTemplateComponent<String>
/// growth limit fits, `justify-content:normal` stretches all auto tracks by an
/// equal share; narrower/cyclic cases decline this fast path.
///
/// The current style model does not expose orthogonal writing modes and emits
/// Taffy's default LTR direction for every grid; this physical-column reduction
/// therefore cannot accidentally enter an RTL/orthogonal layout path that the
/// surrounding renderer does not implement yet. Percentage-width parents are
/// The current style model does not expose orthogonal writing modes. RTL grids
/// are deliberately excluded because this physical-column reduction has not
/// yet been mirrored into logical track coordinates. Percentage-width parents are
/// accepted only after the preliminary layout produced finite, explicit used
/// track sizes; that resolved track sum is the definite basis for pass two.
fn apply_full_span_column_subgrids<F>(
@@ -7008,6 +7025,7 @@ where
for (&dom, style) in styles {
if style.display != crate::Display::Grid
|| style.direction == Some(taffy::Direction::Rtl)
|| style.grid_template_columns_subgrid
|| !matches!(
style.width,
@@ -8452,6 +8470,10 @@ fn build_any(
}
}
let outer_style = taffy::Style {
direction: styles
.get(&id)
.and_then(|style| style.direction)
.unwrap_or(taffy::Direction::Ltr),
display: taffy::style::Display::Flex,
flex_direction: taffy::FlexDirection::Row,
flex_wrap: taffy::FlexWrap::Wrap,
@@ -10869,28 +10891,33 @@ fn build(
}
}
if let Some((width, height)) = crate::inline::default_replaced_intrinsic_size(
_name.local.as_ref(),
style.font_size.unwrap_or(16.0),
node.get_attribute("controls").is_some(),
_name.local.as_ref() != "embed"
|| node
.get_attribute("src")
.is_some_and(|value| !value.trim().is_empty()),
) {
let context = engine.register_replaced(width, height, style);
let leaf = taffy_tree
.new_leaf_with_context(taffy_style, context)
.ok()?;
id_map.insert(leaf, id);
return Some(leaf);
// A loaded poster supplies `<video>`'s replaced-content dimensions before
// decoded video metadata exists. Only use HTML's 300x150 fallback when no
// poster intrinsic is available.
if !(_name.local.as_ref() == "video" && style.replaced_intrinsic.is_some()) {
if let Some((width, height)) = crate::inline::default_replaced_intrinsic_size(
_name.local.as_ref(),
style.font_size.unwrap_or(16.0),
node.get_attribute("controls").is_some(),
_name.local.as_ref() != "embed"
|| node
.get_attribute("src")
.is_some_and(|value| !value.trim().is_empty()),
) {
let context = engine.register_replaced(width, height, style);
let leaf = taffy_tree
.new_leaf_with_context(taffy_style, context)
.ok()?;
id_map.insert(leaf, id);
return Some(leaf);
}
}
// A replaced image is a measured leaf, even when CSS gives it a percentage
// width. Its intrinsic dimensions participate in an auto-sized ancestor's
// max-content measurement; once the percentage axis becomes definite, the
// measure callback derives the other axis through the intrinsic ratio.
if _name.local.as_ref() == "img" {
if matches!(_name.local.as_ref(), "img" | "video") {
if let Some(intrinsic) = style.replaced_intrinsic.or_else(|| {
style
.intrinsic_size
@@ -11955,6 +11982,7 @@ fn run_wrapper_style(parent: &crate::LayoutStyle, has_text_strut: bool) -> taffy
0.0
};
taffy::Style {
direction: parent.direction.unwrap_or(taffy::Direction::Ltr),
display: taffy::style::Display::Flex,
flex_direction: taffy::FlexDirection::Row,
flex_wrap: taffy::FlexWrap::Wrap,
@@ -16588,6 +16616,46 @@ mod tests {
);
}
#[test]
fn inherited_rtl_direction_controls_flex_grid_and_block_start_edges() {
let tree = parse_html(
r#"<style>
html,body { margin:0; direction:rtl }
.case { width:300px; height:40px; margin-bottom:20px }
.flex { display:flex }
.grid { display:grid; grid-template-columns:50px 50px }
.item { width:50px; height:40px }
.block > div { width:50px; height:40px }
</style>
<div id="flex" class="case flex"><div id="flex-one" class="item"></div><div id="flex-two" class="item"></div></div>
<div id="grid" class="case grid"><div id="grid-one" class="item"></div><div id="grid-two" class="item"></div></div>
<div class="case block"><div id="block"></div></div>"#,
);
let laid = layout_dom(&tree, (900.0, 300.0));
let x = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()].x;
assert!((x("flex-one") - 850.0).abs() < 0.1, "{}", x("flex-one"));
assert!((x("flex-two") - 800.0).abs() < 0.1, "{}", x("flex-two"));
assert!((x("grid-one") - 850.0).abs() < 0.1, "{}", x("grid-one"));
assert!((x("grid-two") - 800.0).abs() < 0.1, "{}", x("grid-two"));
assert!((x("block") - 850.0).abs() < 0.1, "{}", x("block"));
for id in ["flex", "grid"] {
let node = tree.get_element_by_id(id).unwrap();
assert_eq!(laid.styles[&node].direction, Some(taffy::Direction::Rtl));
}
}
#[test]
fn html_dir_hint_is_overridden_by_author_css_and_inherits() {
let tree = parse_html(
r#"<style>#override { direction:ltr }</style>
<div dir="rtl"><div id="inherited"></div><div id="override"></div></div>"#,
);
let laid = layout_dom(&tree, (900.0, 300.0));
let style = |id: &str| &laid.styles[&tree.get_element_by_id(id).unwrap()];
assert_eq!(style("inherited").direction, Some(taffy::Direction::Rtl));
assert_eq!(style("override").direction, Some(taffy::Direction::Ltr));
}
#[test]
fn authored_table_with_mixed_direct_content_preserves_non_cell_child() {
let tree = parse_html(
+32
View File
@@ -764,6 +764,9 @@ pub enum GeneratedContentItem {
#[derive(Debug, Clone, Default)]
pub struct LayoutStyle {
pub display: Display,
/// Computed inline base direction. `None` is the inherited specified
/// state before the DOM top-down pass resolves it.
pub direction: Option<taffy::Direction>,
/// The specified `display` value was the CSS-wide `inherit` keyword.
///
/// `display` is normally non-inherited, so the cascade cannot resolve this
@@ -1348,6 +1351,10 @@ pub struct LayoutStyle {
/// `Fill` (default) stretches to the box, the rest preserve aspect ratio.
/// Only consulted in the image paint path.
pub object_fit: ObjectFit,
/// `object-position` for replaced image content. Percentages resolve
/// against the leftover space after `object-fit`; the CSS initial value
/// is centered on both axes.
pub object_position: ObjectPosition,
/// Ordered operations in the non-inherited `transform` property. Length
/// percentages remain unresolved until the final border box is known.
pub transform_ops: Vec<TransformOp>,
@@ -1528,6 +1535,30 @@ pub enum ObjectFit {
None,
}
/// `object-position` for replaced image content. It shares the same
/// length-percentage positioning model as backgrounds, but has a centered
/// initial value instead of `background-position`'s start-edge default.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ObjectPosition {
pub x: BackgroundPositionAxis,
pub y: BackgroundPositionAxis,
}
impl ObjectPosition {
pub const fn new(x: BackgroundPositionAxis, y: BackgroundPositionAxis) -> Self {
Self { x, y }
}
}
impl Default for ObjectPosition {
fn default() -> Self {
Self {
x: BackgroundPositionAxis::percentage(0.5),
y: BackgroundPositionAxis::percentage(0.5),
}
}
}
/// `vertical-align` positions for table-cell content. `baseline` (and the
/// text-level values like sub/super, which do not apply to cells) map to
/// `Top` as an approximation: real per-row baseline alignment needs shared
@@ -2096,6 +2127,7 @@ fn read_node(tree: &TaffyTree, id: NodeId) -> NodeRect {
pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
let mut s = Style::DEFAULT;
s.direction = style.direction.unwrap_or(taffy::Direction::Ltr);
s.item_is_replaced = style.has_replaced_sizing;
s.item_aspect_ratio_is_intrinsic = style.aspect_ratio_is_intrinsic;
s.box_sizing = match style.box_sizing {
+129 -18
View File
@@ -371,6 +371,40 @@ impl RenderResourceCache {
}
}
/// Cache-only poster selection for one live `<video>`. Poster images use
/// the element's media CORS settings and the same page-owned image cache as
/// ordinary replaced images; they do not imply that any video frame has
/// loaded or decoded.
pub fn cached_video_poster_metadata(
&self,
tree: &DomTree,
id: obscura_dom::tree::NodeId,
base_url: Option<&str>,
) -> Option<(String, ImageRequestProfile, bool, Option<(f32, f32)>)> {
let node = tree.get_node(id)?;
if node
.as_element()
.is_none_or(|element| element.local.as_ref() != "video")
{
return None;
}
let poster = node.get_attribute("poster")?.trim();
if poster.is_empty() {
return None;
}
let resolved_url = resolve_resource_url(poster, base_url)?;
let profile = image_request_profile(tree, id);
match self.cached_profiled_image_metadata(&resolved_url, None, profile) {
None => Some((resolved_url, profile, false, None)),
Some(dimensions) => Some((
resolved_url,
profile,
true,
dimensions.map(|(_, width, height)| (width, height)),
)),
}
}
fn get_or_load(&mut self, url: &str) -> Option<Arc<[u8]>> {
let url = network_resource_url(url);
if let Some(entry) = self.entries.get(&url) {
@@ -3437,6 +3471,7 @@ fn paint_laid_dom_scrolled(
&img_rect,
&background.clip_rect,
crate::ObjectFit::Fill,
crate::ObjectPosition::default(),
&mut pixmap,
image_cache,
None,
@@ -3530,7 +3565,7 @@ fn paint_laid_dom_scrolled(
raster_scale,
);
if box_on_surface && name.local.as_ref() == "img" {
if box_on_surface && matches!(name.local.as_ref(), "img" | "video") {
if let Some(source) = selected_images.get(&nid) {
// `visible_rect` is the border box already intersected with the
// ancestor overflow clip: the raster must not paint past it (a
@@ -3542,6 +3577,7 @@ fn paint_laid_dom_scrolled(
&rect,
&visible_rect,
style.object_fit,
style.object_position,
&mut pixmap,
image_cache,
Some(source.profile),
@@ -3557,7 +3593,7 @@ fn paint_laid_dom_scrolled(
// grey placeholder. box_rect/visible_rect are already
// clip-intersected, so none of this paints outside an
// overflow:hidden clip.
if !painted {
if !painted && name.local.as_ref() == "img" {
match node.get_attribute("alt") {
Some(alt) if !alt.trim().is_empty() => {
draw_text(
@@ -4191,6 +4227,7 @@ fn paint_inline_fragment_decorations(
&image_rect,
&background.clip_rect,
crate::ObjectFit::Fill,
crate::ObjectPosition::default(),
pixmap,
image_cache,
None,
@@ -7476,6 +7513,7 @@ fn paint_in_flow_generated_box(
&image_rect,
&background.clip_rect,
crate::ObjectFit::Fill,
crate::ObjectPosition::default(),
pixmap,
image_cache,
None,
@@ -7718,6 +7756,7 @@ fn paint_positioned_pseudo(
&image_rect,
&background.clip_rect,
crate::ObjectFit::Fill,
crate::ObjectPosition::default(),
pixmap,
image_cache,
None,
@@ -7787,7 +7826,10 @@ fn paint_positioned_pseudo(
/// Fetch every `<img>` once (seeding `cache` for the paint pass) and record its
/// intrinsic (width, height) so layout can size replaced elements that have no
/// explicit dimensions. Keyed by the `<img>`'s NodeId.
/// explicit dimensions. Video posters are image resources too: before a
/// decoded frame exists, their intrinsic dimensions and pixels are the
/// replaced content Chromium paints for `<video>`. Keyed by the element's
/// NodeId.
fn collect_image_intrinsics(
tree: &DomTree,
viewport: (f32, f32),
@@ -7803,16 +7845,28 @@ fn collect_image_intrinsics(
let Some(node) = tree.get_node(nid) else {
continue;
};
if node
.as_element()
.map(|e| e.local.as_ref() != "img")
.unwrap_or(true)
{
continue;
}
let Some((url, density)) = resolve_img_url(tree, nid, viewport) else {
let Some(element) = node.as_element() else {
continue;
};
let (url, density) = match element.local.as_ref() {
"img" => {
let Some(candidate) = resolve_img_url(tree, nid, viewport) else {
continue;
};
candidate
}
"video" => {
let Some(poster) = node.get_attribute("poster") else {
continue;
};
let poster = poster.trim();
if poster.is_empty() {
continue;
}
(poster.to_string(), 1.0)
}
_ => continue,
};
let resolved_url = resolve_resource_url(&url, base_url).unwrap_or(url);
let profile = image_request_profile(tree, nid);
selected.insert(
@@ -8187,6 +8241,7 @@ fn paint_image(
rect: &crate::Rect,
visible_rect: &crate::Rect,
object_fit: crate::ObjectFit,
object_position: crate::ObjectPosition,
pixmap: &mut Pixmap,
cache: &mut RenderResourceCache,
profile: Option<ImageRequestProfile>,
@@ -8225,7 +8280,9 @@ fn paint_image(
image_dimensions(&bytes).map(|(w, h)| (w as f32, h as f32))
};
match intrinsic {
Some((iw, ih)) => object_fit_dest(rect, iw, ih, object_fit),
Some((iw, ih)) => {
object_fit_dest_positioned(rect, iw, ih, object_fit, object_position)
}
None => *rect,
}
};
@@ -8311,11 +8368,21 @@ fn paint_image(
true
}
/// The destination sub-rect for a replaced element's image within its box,
/// given the image's intrinsic `(iw, ih)` size and `object-fit`. Centered in
/// the box; for `Cover`/`None` it can extend past the box edges (the caller
/// clips it). Aspect ratio is preserved for every mode except `Fill`.
/// The destination sub-rect for replaced image content within its box. The
/// position resolves against the leftover space after `object-fit`; for
/// `Cover`/`None` that space can be negative and the caller clips the result.
#[cfg(test)]
fn object_fit_dest(box_rect: &crate::Rect, iw: f32, ih: f32, fit: crate::ObjectFit) -> crate::Rect {
object_fit_dest_positioned(box_rect, iw, ih, fit, crate::ObjectPosition::default())
}
fn object_fit_dest_positioned(
box_rect: &crate::Rect,
iw: f32,
ih: f32,
fit: crate::ObjectFit,
position: crate::ObjectPosition,
) -> crate::Rect {
let (bw, bh) = (box_rect.width, box_rect.height);
if iw <= 0.0 || ih <= 0.0 {
return *box_rect;
@@ -8339,8 +8406,8 @@ fn object_fit_dest(box_rect: &crate::Rect, iw: f32, ih: f32, fit: crate::ObjectF
}
};
crate::Rect {
x: box_rect.x + (bw - dw) / 2.0,
y: box_rect.y + (bh - dh) / 2.0,
x: box_rect.x + position.x.resolve(bw - dw),
y: box_rect.y + position.y.resolve(bh - dh),
width: dw,
height: dh,
}
@@ -12382,6 +12449,49 @@ mod tests {
);
}
#[test]
fn video_poster_supplies_intrinsic_size_and_paints_as_replaced_content() {
const POSTER: &str = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='300'%20height='100'%3E%3Crect%20width='100'%20height='100'%20fill='%23ff0000'/%3E%3Crect%20x='100'%20width='100'%20height='100'%20fill='%2300ff00'/%3E%3Crect%20x='200'%20width='100'%20height='100'%20fill='%230000ff'/%3E%3C/svg%3E";
let tree = parse_html(&format!(
r#"<html><body style="margin:0;background:white">
<video id="poster" style="position:absolute;left:0;top:0" poster="{POSTER}"></video>
<video id="positioned" style="position:absolute;left:0;top:110px;width:100px;height:100px;object-fit:cover;object-position:right center;border-radius:20px;opacity:.5" poster="{POSTER}"></video>
</body></html>"#
));
let mut resources = RenderResourceCache::default();
let mut prepared = prepare_dom(&tree, (360.0, 220.0), None, &mut resources)
.expect("poster-aware layout");
let video = tree.get_element_by_id("poster").expect("video");
let rect = prepared.document_rect(video).expect("video rect");
assert_eq!((rect.width, rect.height), (300.0, 100.0));
let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0))
.expect("poster paint");
let red = pixmap.pixel(50, 50).expect("red stripe");
let green = pixmap.pixel(150, 50).expect("green stripe");
let blue = pixmap.pixel(250, 50).expect("blue stripe");
assert!(red.red() > 240 && red.green() < 20 && red.blue() < 20);
assert!(green.green() > 240 && green.red() < 20 && green.blue() < 20);
assert!(blue.blue() > 240 && blue.red() < 20 && blue.green() < 20);
let rounded_corner = pixmap.pixel(0, 110).expect("rounded corner");
assert!(
rounded_corner.red() > 245
&& rounded_corner.green() > 245
&& rounded_corner.blue() > 245,
"poster must be clipped by the video radius: {rounded_corner:?}"
);
let positioned = pixmap.pixel(50, 160).expect("positioned poster center");
assert!(
positioned.red() > 100
&& positioned.red() < 160
&& positioned.green() > 100
&& positioned.green() < 160
&& positioned.blue() > 240,
"right object-position must select the blue stripe and opacity must composite it: {positioned:?}"
);
}
#[test]
fn projected_image_transform_enters_overflow_clip() {
// Chromium reduction: without the projected rotate/scale, this image is
@@ -13729,6 +13839,7 @@ mod tests {
&rect,
&rect,
crate::ObjectFit::Fill,
crate::ObjectPosition::default(),
&mut pixmap,
&mut resources,
None,
+47
View File
@@ -700,6 +700,14 @@ fn unitless_math_tokens<'i, 't>(
fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
match name {
"direction" => match value.trim().to_ascii_lowercase().as_str() {
"ltr" | "initial" | "revert" | "revert-layer" => {
style.direction = Some(taffy::Direction::Ltr);
}
"rtl" => style.direction = Some(taffy::Direction::Rtl),
"inherit" | "unset" => style.direction = None,
_ => {}
},
"display" => {
let value = value.trim().to_ascii_lowercase();
if matches!(
@@ -1318,6 +1326,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
"none" => style.object_fit = crate::ObjectFit::None,
_ => {}
},
"object-position" => {
let position = parse_background_position(value);
style.object_position = crate::ObjectPosition::new(position.x, position.y);
}
"top" => set_inset_side(style, 0, value),
"right" => set_inset_side(style, 1, value),
"bottom" => set_inset_side(style, 2, value),
@@ -1865,6 +1877,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool {
let known = matches!(
name.as_str(),
"display"
| "direction"
| "width"
| "inline-size"
| "height"
@@ -2004,6 +2017,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool {
| "counter-increment"
| "counter-set"
| "object-fit"
| "object-position"
| "top"
| "right"
| "bottom"
@@ -2121,6 +2135,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool {
| "-webkit-inline-box"
| "contents"
),
"direction" => matches!(value.to_ascii_lowercase().as_str(), "ltr" | "rtl"),
"position" => matches!(
value.to_ascii_lowercase().as_str(),
"static" | "relative" | "absolute" | "fixed" | "sticky"
@@ -2188,6 +2203,19 @@ pub fn supports_declaration(name: &str, value: &str) -> bool {
value.to_ascii_lowercase().as_str(),
"fill" | "contain" | "cover" | "none" | "scale-down"
),
"object-position" => {
let tokens = split_ws_paren(value);
!tokens.is_empty()
&& tokens.len() <= 4
&& tokens.iter().all(|token| {
matches!(*token, "left" | "right" | "top" | "bottom" | "center")
|| token
.strip_suffix('%')
.and_then(|number| number.parse::<f32>().ok())
.is_some_and(f32::is_finite)
|| px_value(token).is_some_and(f32::is_finite)
})
}
"visibility" => matches!(
value.to_ascii_lowercase().as_str(),
"visible" | "hidden" | "collapse"
@@ -8056,6 +8084,25 @@ fn split_ws_paren(s: &str) -> Vec<&str> {
mod tests {
use super::*;
#[test]
fn direction_parses_inherited_state_and_supports_only_real_values() {
assert_eq!(
compute_style("div", Some("direction:rtl")).direction,
Some(taffy::Direction::Rtl)
);
assert_eq!(
compute_style("div", Some("direction:rtl;direction:initial")).direction,
Some(taffy::Direction::Ltr)
);
assert_eq!(
compute_style("div", Some("direction:rtl;direction:inherit")).direction,
None
);
assert!(supports_declaration("direction", "rtl"));
assert!(supports_declaration("direction", "ltr"));
assert!(!supports_declaration("direction", "auto"));
}
#[test]
fn table_layout_parses_resets_and_reports_only_supported_values() {
let fixed = compute_style("table", Some("table-layout:fixed"));