Skip to content

Commit 4fb0500

Browse files
authored
Merge pull request gosub-io#1078 from jaytaph/image-fix
Fixes the hackernews logo display
2 parents 2fc77a8 + e77ff3a commit 4fb0500

21 files changed

Lines changed: 561 additions & 379 deletions

File tree

.cargo/config.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,15 @@
11
[registries]
22
gosub = { index = "sparse+https://registry.gosub.io/" }
3+
4+
# Faster linker for the large binaries (gosub-screenshot, the example browsers).
5+
# mold is typically several times faster than the default bfd/gold linker on the
6+
# big link steps. Install it first: apt install mold (or dnf/pacman install mold)
7+
# then uncomment the block below. Verify with: mold --version
8+
#
9+
# [target.x86_64-unknown-linux-gnu]
10+
# linker = "clang"
11+
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
12+
#
13+
# If you don't have clang, use rustc's bundled cc driver instead:
14+
# [target.x86_64-unknown-linux-gnu]
15+
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,15 @@ gosub_render_pipeline = { path = "./crates/gosub_render_pipeline", features = ["
217217
vello = { git = "https://github.com/linebender/vello", rev = "1b24e77ba4e01d12f5db86a9b1b5f2cf828a6a33" }
218218
vello_encoding = { git = "https://github.com/linebender/vello", rev = "1b24e77ba4e01d12f5db86a9b1b5f2cf828a6a33" }
219219

220+
[profile.dev]
221+
# Keep the linker from copying the (very large) DWARF into every binary on each
222+
# link — debug info stays in the .o files instead. Combined with line-tables-only
223+
# this cuts re-link time of the big binaries (gosub-screenshot, the example
224+
# browsers) from tens of seconds to a few, and shrinks them ~5x. Backtraces still
225+
# have file/line; only local-variable inspection is dropped.
226+
split-debuginfo = "unpacked"
227+
debug = "line-tables-only"
228+
220229
[profile.release]
221230
lto = "fat"
222231
opt-level = 3

crates/gosub_engine/src/engine/context.rs

Lines changed: 25 additions & 185 deletions
Large diffs are not rendered by default.

crates/gosub_lattice/src/sizing/columns.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,17 @@ pub fn compute_column_widths<T: TableTree>(
4040
for cell in grid.cells_in_row(row_idx) {
4141
found_any = true;
4242
if cell.colspan == 1 {
43+
let cw = tree.cell_content_width(cell.node);
4344
if explicit[cell.col].is_none() {
45+
// A specified width cannot shrink a cell below its content's min-width
46+
// (CSS: used width = max(specified, min-content)). Without this, e.g. a
47+
// `width:18px` cell holding a 20px image clips it and eats the padding.
4448
match tree.css_length(cell.node, CssProp::Width) {
45-
CssLength::Px(px) => explicit[cell.col] = Some(px),
46-
CssLength::Percent(p) => explicit[cell.col] = Some(p / 100.0 * table_width),
49+
CssLength::Px(px) => explicit[cell.col] = Some(px.max(cw)),
50+
CssLength::Percent(p) => explicit[cell.col] = Some((p / 100.0 * table_width).max(cw)),
4751
_ => {}
4852
}
4953
}
50-
let cw = tree.cell_content_width(cell.node);
5154
if cw > natural[cell.col] {
5255
natural[cell.col] = cw;
5356
}

crates/gosub_render_pipeline/src/common/media/media_store.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,14 +317,20 @@ fn detect_content_type(content_type: &str) -> Option<MediaType> {
317317
}
318318

319319
fn ft_detect(ft: &FileType) -> Option<MediaType> {
320+
// Scan *all* media types before deciding. The `file_type` crate lists several aliases
321+
// for SVG (e.g. `["image/SVG", "image/svg+xml"]`); a naive first-match-wins loop trips
322+
// the generic `image/` branch on the non-standard `image/SVG` alias and misclassifies
323+
// SVG as a raster image. So look for any SVG marker first, and only fall back to a
324+
// generic raster image if none is found. Comparisons are case-insensitive.
325+
let mut is_raster_image = false;
320326
for &mt in ft.media_types().iter() {
321-
if mt == "image/svg+xml" {
327+
if mt.eq_ignore_ascii_case("image/svg+xml") || mt.eq_ignore_ascii_case("image/svg") {
322328
return Some(MediaType::Svg);
323329
}
324-
if mt.starts_with("image/") {
325-
return Some(MediaType::Image);
330+
if mt.len() >= 6 && mt[..6].eq_ignore_ascii_case("image/") {
331+
is_raster_image = true;
326332
}
327333
}
328334

329-
None
335+
is_raster_image.then_some(MediaType::Image)
330336
}

crates/gosub_render_pipeline/src/layouter/table.rs

Lines changed: 97 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,33 @@ impl<'a> PipelineTableTree<'a> {
3535
}
3636
}
3737

38+
/// Sum of the border-box heights of the nested tables directly contained in a cell (not
39+
/// counting tables nested deeper inside those). Zero if the cell holds no table. This lets
40+
/// a table cell grow to contain a nested table whose height lattice computes in a later pass.
41+
fn nested_table_height(&self, cell_layout_id: LayoutElementId) -> f32 {
42+
let Some(el) = self.layout_tree.arena.get(&cell_layout_id) else {
43+
return 0.0;
44+
};
45+
let mut total = 0.0;
46+
for &child_id in &el.children {
47+
let Some(child) = self.layout_tree.arena.get(&child_id) else {
48+
continue;
49+
};
50+
let is_table = matches!(
51+
self.doc.get_own_style(child.dom_node_id, &StyleProperty::Display),
52+
Some(Value::Display(Display::Table))
53+
);
54+
if is_table {
55+
// Self-contained nested table — stop here, don't double-count its inner tables.
56+
total += child.box_model.border_box.height as f32;
57+
} else {
58+
// The table may be wrapped (e.g. in an anonymous box); keep descending.
59+
total += self.nested_table_height(child_id);
60+
}
61+
}
62+
total
63+
}
64+
3865
/// Convert pending relative positions to absolute `BoxModel`s in the arena.
3966
/// Must be called after `compute_table_layout` returns.
4067
pub fn apply_positions(&mut self, table_dom_id: DomNodeId) {
@@ -153,8 +180,10 @@ fn cell_layout_to_box_model(layout: &CellLayout, abs: Coordinate) -> BoxModel {
153180
fn intrinsic_content_width(el: &LayoutElementNode, arena: &HashMap<LayoutElementId, LayoutElementNode>) -> f32 {
154181
match &el.context {
155182
ElementContext::Text(_) => el.box_model.content_box.width as f32,
156-
ElementContext::Image(ctx) => ctx.dimension.width as f32,
157-
ElementContext::Svg(ctx) => ctx.dimension.width as f32,
183+
// Replaced elements: use the laid-out border-box width so the column is wide enough for
184+
// the image *including its own CSS border* (the bare `dimension` omits it). Images are
185+
// never stretched to the cell width, so the border box is the true intrinsic width.
186+
ElementContext::Image(_) | ElementContext::Svg(_) => el.box_model.border_box.width as f32,
158187
ElementContext::None => el
159188
.children
160189
.iter()
@@ -239,7 +268,11 @@ impl TableTree for PipelineTableTree<'_> {
239268
// than 0 and covers the most common case (single column of text).
240269
if let Some(&layout_id) = self.dom_to_layout.get(&id) {
241270
if let Some(element) = self.layout_tree.arena.get(&layout_id) {
242-
return element.box_model.content_box.height as f32;
271+
let taffy_h = element.box_model.content_box.height as f32;
272+
// A cell containing a nested table must be at least as tall as that table.
273+
// The nested table's real height is only known after lattice lays it out, so
274+
// the second (bottom-up) pass in `post_process_tables` propagates it up here.
275+
return taffy_h.max(self.nested_table_height(layout_id));
243276
}
244277
}
245278
0.0
@@ -248,7 +281,10 @@ impl TableTree for PipelineTableTree<'_> {
248281
fn cell_content_width(&self, id: DomNodeId) -> f32 {
249282
if let Some(&layout_id) = self.dom_to_layout.get(&id) {
250283
if let Some(element) = self.layout_tree.arena.get(&layout_id) {
251-
return intrinsic_content_width(element, &self.layout_tree.arena);
284+
// Include the cell's own horizontal padding so the column is wide enough to hold
285+
// the content *and* its padding (e.g. HN's logo cell: 20px image + 4px padding-right).
286+
let pad = (element.box_model.padding.left + element.box_model.padding.right) as f32;
287+
return intrinsic_content_width(element, &self.layout_tree.arena) + pad;
252288
}
253289
}
254290
0.0
@@ -273,59 +309,69 @@ pub fn post_process_tables(layout_tree: &mut LayoutTree, dom_to_layout: &HashMap
273309

274310
log::info!("lattice: post_process_tables found {} table node(s)", table_nodes.len());
275311

276-
for (table_dom_id, table_layout_id) in table_nodes {
277-
// Use the parent element's content width as available_width. For nested
278-
// tables the parent is a table cell whose box model was already updated
279-
// by the outer table's apply_positions call, giving us the correct width.
280-
// Fall back to the table's own Taffy-computed width for root-level tables.
281-
let available_width = doc
282-
.parent(table_dom_id)
283-
.and_then(|p| dom_to_layout.get(&p))
284-
.and_then(|&pid| layout_tree.arena.get(&pid))
285-
.map(|el| el.box_model.content_box.width as f32)
286-
.unwrap_or_else(|| {
287-
layout_tree
288-
.arena
289-
.get(&table_layout_id)
290-
.map(|e| e.box_model.content_box.width as f32)
291-
.unwrap_or(0.0)
292-
});
312+
// Two passes. Pass 1 is pre-order (outer→inner): it establishes column widths, which flow
313+
// top-down (a nested table reads its width from its already-sized parent cell). Pass 2 is
314+
// post-order (inner→outer): each table is re-laid-out *after* the tables nested inside its
315+
// cells, so an outer cell's height now reflects its nested table's true height — height
316+
// flows bottom-up. A single reverse pass propagates through any table-nesting depth.
317+
for pass in 0..2 {
318+
let order: Vec<(DomNodeId, LayoutElementId)> = if pass == 0 {
319+
table_nodes.clone()
320+
} else {
321+
table_nodes.iter().rev().copied().collect()
322+
};
323+
for (table_dom_id, table_layout_id) in order {
324+
lay_out_one_table(&*doc, layout_tree, dom_to_layout, table_dom_id, table_layout_id);
325+
}
326+
}
327+
}
293328

294-
log::info!(
295-
"lattice: computing layout for table node {:?}, available_width={}",
296-
table_dom_id,
297-
available_width
298-
);
329+
/// Run lattice for a single table node and write the computed cell positions and the table's
330+
/// own size back into the layout tree.
331+
fn lay_out_one_table(
332+
doc: &dyn PipelineDocument,
333+
layout_tree: &mut LayoutTree,
334+
dom_to_layout: &HashMap<DomNodeId, LayoutElementId>,
335+
table_dom_id: DomNodeId,
336+
table_layout_id: LayoutElementId,
337+
) {
338+
// Use the parent element's content width as available_width. For nested
339+
// tables the parent is a table cell whose box model was already updated
340+
// by the outer table's apply_positions call, giving us the correct width.
341+
// Fall back to the table's own Taffy-computed width for root-level tables.
342+
let available_width = doc
343+
.parent(table_dom_id)
344+
.and_then(|p| dom_to_layout.get(&p))
345+
.and_then(|&pid| layout_tree.arena.get(&pid))
346+
.map(|el| el.box_model.content_box.width as f32)
347+
.unwrap_or_else(|| {
348+
layout_tree
349+
.arena
350+
.get(&table_layout_id)
351+
.map(|e| e.box_model.content_box.width as f32)
352+
.unwrap_or(0.0)
353+
});
299354

300-
// &*doc borrows from our local Arc, not from layout_tree — no conflict.
301-
let mut tree = PipelineTableTree::new(&*doc, layout_tree, dom_to_layout);
355+
let mut tree = PipelineTableTree::new(doc, layout_tree, dom_to_layout);
302356

303-
match gosub_lattice::compute_table_layout(&mut tree, table_dom_id, available_width, None) {
304-
Ok((table_width, table_height)) => {
305-
log::info!(
306-
"lattice: table node {:?} → {}×{}px, pending={}",
307-
table_dom_id,
308-
table_width,
309-
table_height,
310-
tree.pending.len()
357+
match gosub_lattice::compute_table_layout(&mut tree, table_dom_id, available_width, None) {
358+
Ok((table_width, table_height)) => {
359+
tree.apply_positions(table_dom_id);
360+
// Write back both dimensions so deeply-nested tables can read the
361+
// correct width from this table's box model via their parent lookup.
362+
if let Some(el) = layout_tree.arena.get_mut(&table_layout_id) {
363+
let bb = el.box_model.border_box;
364+
el.box_model = BoxModel::new(
365+
Rect::new(bb.x, bb.y, table_width as f64, table_height as f64),
366+
el.box_model.padding,
367+
el.box_model.border,
368+
el.box_model.margin,
311369
);
312-
tree.apply_positions(table_dom_id);
313-
// Write back both dimensions so deeply-nested tables can read the
314-
// correct width from this table's box model via their parent lookup.
315-
if let Some(el) = layout_tree.arena.get_mut(&table_layout_id) {
316-
let bb = el.box_model.border_box;
317-
el.box_model = BoxModel::new(
318-
Rect::new(bb.x, bb.y, table_width as f64, table_height as f64),
319-
el.box_model.padding,
320-
el.box_model.border,
321-
el.box_model.margin,
322-
);
323-
}
324-
}
325-
Err(e) => {
326-
log::warn!("lattice: table layout failed for node {:?}: {:?}", table_dom_id, e);
327370
}
328371
}
372+
Err(e) => {
373+
log::warn!("lattice: table layout failed for node {:?}: {:?}", table_dom_id, e);
374+
}
329375
}
330376
}
331377

crates/gosub_render_pipeline/src/layouter/taffy.rs

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ pub struct TaffyLayouter {
4343
/// populate_boxmodel uses this to add the container's taffy-computed offset to the offset
4444
/// it passes down to the child, which would otherwise be missing from the calculation.
4545
anon_container_map: HashMap<LayoutElementId, TaffyNodeId>,
46-
/// Media store for loading images/SVGs during layout
47-
media_store: MediaStore,
46+
/// Media store for loading images/SVGs during layout. Shared (Arc) so the media loaded
47+
/// here is visible to the rasterization stage, which looks resources up by the same id.
48+
media_store: Arc<MediaStore>,
4849
/// Shared font system used for text measurement. Locking once per measurement
4950
/// call avoids keeping the guard alive across the full layout pass, which lets
5051
/// other threads (e.g. the rasterizer) access the font collection between calls.
@@ -93,12 +94,12 @@ impl TaffyContext {
9394
})
9495
}
9596

96-
fn svg(src: &str, media_id: MediaId, node_id: DomNodeId) -> TaffyContext {
97+
fn svg(src: &str, media_id: MediaId, dimension: geo::Dimension, node_id: DomNodeId) -> TaffyContext {
9798
TaffyContext::Svg(ElementContextSvg {
9899
node_id,
99100
src: src.to_string(),
100101
media_id,
101-
dimension: geo::Dimension::ZERO,
102+
dimension,
102103
})
103104
}
104105
}
@@ -125,7 +126,7 @@ impl TaffyLayouter {
125126
root_id: TaffyNodeId::new(0),
126127
layout_taffy_mapping: HashMap::new(),
127128
anon_container_map: HashMap::new(),
128-
media_store: MediaStore::new(),
129+
media_store: Arc::new(MediaStore::new()),
129130
font_system,
130131
measure_cache: HashMap::new(),
131132
dom_to_layout_mapping: HashMap::new(),
@@ -137,6 +138,18 @@ impl TaffyLayouter {
137138
Arc::clone(&self.font_system)
138139
}
139140

141+
/// Share an external media store with this layouter. Resources loaded during layout are
142+
/// stored here; passing the same store to the rasterizer lets it resolve those resources
143+
/// by id. Without this they live in two separate stores and images render as placeholders.
144+
pub fn set_media_store(&mut self, media_store: Arc<MediaStore>) {
145+
self.media_store = media_store;
146+
}
147+
148+
/// The media store shared by this layouter (see [`set_media_store`](Self::set_media_store)).
149+
pub fn media_store(&self) -> Arc<MediaStore> {
150+
Arc::clone(&self.media_store)
151+
}
152+
140153
pub fn print_tree(&mut self) {
141154
self.tree.print_tree(self.root_id);
142155
}
@@ -262,6 +275,12 @@ impl CanLayout for TaffyLayouter {
262275
width: image_ctx.dimension.width as f32,
263276
height: image_ctx.dimension.height as f32,
264277
},
278+
// SVG-backed <img> elements carry their intrinsic size the same way.
279+
// Without this arm they measured as 0×0 and collapsed (e.g. the HN logo).
280+
Some(TaffyContext::Svg(svg_ctx)) => Size {
281+
width: svg_ctx.dimension.width as f32,
282+
height: svg_ctx.dimension.height as f32,
283+
},
265284
_ => Size::ZERO,
266285
}
267286
})
@@ -637,7 +656,17 @@ impl TaffyLayouter {
637656
// occupies, so display quality is unaffected.
638657
let is_placeholder = self.media_store.is_placeholder(media_id);
639658
taffy_context = match media.borrow() {
640-
Media::Svg(_) => Some(TaffyContext::svg(src.as_str(), media_id, dom_node.node_id)),
659+
Media::Svg(media_svg) => {
660+
// Use the SVG's intrinsic size so the element gets a non-zero box.
661+
// A failed/placeholder load uses the same small fixed size as images.
662+
let dimension = if is_placeholder {
663+
geo::Dimension::new(32.0, 32.0)
664+
} else {
665+
let size = media_svg.svg.tree.size();
666+
geo::Dimension::new(size.width() as f64, size.height() as f64)
667+
};
668+
Some(TaffyContext::svg(src.as_str(), media_id, dimension, dom_node.node_id))
669+
}
641670
Media::Image(media_image) => {
642671
let dimension = if is_placeholder {
643672
geo::Dimension::new(32.0, 32.0)
@@ -656,7 +685,20 @@ impl TaffyLayouter {
656685
.load_media_from_data(MediaType::Svg, inner_html.into_bytes().as_slice())
657686
{
658687
Ok(media_id) => {
659-
taffy_context = Some(TaffyContext::svg("gosub://internal", media_id, dom_node.node_id));
688+
let media = self.media_store.get(media_id, MediaType::Svg);
689+
let dimension = match media.borrow() {
690+
Media::Svg(media_svg) => {
691+
let size = media_svg.svg.tree.size();
692+
geo::Dimension::new(size.width() as f64, size.height() as f64)
693+
}
694+
_ => geo::Dimension::ZERO,
695+
};
696+
taffy_context = Some(TaffyContext::svg(
697+
"gosub://internal",
698+
media_id,
699+
dimension,
700+
dom_node.node_id,
701+
));
660702
}
661703
Err(e) => {
662704
log::warn!("Could not load SVG media: {:?}", e);

0 commit comments

Comments
 (0)