Support RTL layout and video posters
This commit is contained in:
1 parent
68c81f319c
commit
012a7dce2f
9 files changed
+473
-77
No files matched your search
Vendored
+49
-16
@@ -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;
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user