diff --git a/crates/dom/src/computed.rs b/crates/dom/src/computed.rs index f1353f4b..ef9c1172 100644 --- a/crates/dom/src/computed.rs +++ b/crates/dom/src/computed.rs @@ -15,11 +15,13 @@ use std::collections::BTreeMap; use style::values::computed::{ - Clear as StyloClear, Float as StyloFloat, Inset as StyloInset, Length as StyloLength, - LengthPercentage as StyloLengthPercentage, Margin as StyloMargin, MaxSize as StyloMaxSize, - PositionProperty as StyloPositionProperty, Size as StyloSize, + BaselineShift as StyloBaselineShift, Clear as StyloClear, Float as StyloFloat, + Inset as StyloInset, Length as StyloLength, LengthPercentage as StyloLengthPercentage, + LineHeight as StyloLineHeight, Margin as StyloMargin, MaxSize as StyloMaxSize, + PositionProperty as StyloPositionProperty, Size as StyloSize, TextAlign as StyloTextAlign, TransformOperation as StyloTransformOperation, ZIndex as StyloZIndex, }; +use style::values::generics::box_::BaselineShiftKeyword as StyloBaselineShiftKeyword; use style_traits::{CssWriter, ToCss}; use crate::node::NodeId; @@ -242,6 +244,58 @@ pub fn is_display_flow_root(document: &Document, node: NodeId) -> bool { document.computed_style(node).is_some_and(|style| to_css_string(&style.clone_display()) == "flow-root") } +/// The computed `display`, as far as box construction currently distinguishes it +/// (`crates/layout/src/boxtree.rs`). Not a full CSS Display 3 type: the inside/outside +/// pair is collapsed to the four keywords box construction branches on, and everything +/// else lands in [`Other`](Display::Other), the way [`Position`] collapses `sticky` into +/// `Static` rather than growing a variant nothing reads. `tables-fixed-layout` and +/// `display-contents` extend this enum when their own keywords start to matter. +/// +/// Read by serialization, the same way [`is_display_none`] and [`is_display_flow_root`] +/// already read the property; both keep working unchanged beside this, since neither is +/// reimplemented in terms of it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Display { + Block, + /// The one `display` value that establishes a block formatting context on its own + /// (CSS Display 3 §2.1) -- [`is_display_flow_root`]'s answer, as a variant. + FlowRoot, + /// An atomic inline-level box that lays its own contents out as a block container + /// (CSS Display 3 §2.1): the one inline-level box + /// `crates/layout/src/inline_atomic.rs` places on a line. + InlineBlock, + /// Generates no box at all, and neither does anything in its subtree (CSS 2.1 + /// §9.2.5) -- [`is_display_none`]'s answer, as a variant. + None, + /// Every other keyword: `inline`, `table`, `flex`, `grid`, `contents`, `list-item` + /// and the rest. Each generates a box, and box construction treats it as + /// block-level, which is what the engine did for all of them before this enum + /// existed. + Other, +} + +impl Display { + /// Whether a box with this `display` is inline-level -- participates in an inline + /// formatting context rather than stacking in normal flow (CSS 2.1 §9.2.2). Only + /// [`InlineBlock`](Display::InlineBlock) is, in this build: `inline` itself is out of + /// scope until there is text to put in it (`specs/inline-atomic.md`). + pub fn is_inline_level(self) -> bool { + matches!(self, Display::InlineBlock) + } +} + +pub fn display(document: &Document, node: NodeId) -> Display { + document.computed_style(node).map_or(Display::Block, |style| { + match to_css_string(&style.clone_display()).as_str() { + "none" => Display::None, + "block" => Display::Block, + "flow-root" => Display::FlowRoot, + "inline-block" => Display::InlineBlock, + _ => Display::Other, + } + }) +} + /// CSS 2.1 §9.9.1's `z-index`: `auto` or an integer. Only meaningful on a positioned box /// (an unpositioned box's `z-index` is ignored by CSS 2.1 and by every engine), but the /// accessor reports the computed value regardless and leaves that rule to the @@ -662,6 +716,91 @@ pub fn transform_origin(document: &Document, node: NodeId) -> (LengthPercentage, ) } +/// CSS 2.1 §10.8.1's `vertical-align`, narrowed to the values +/// `crates/layout/src/inline_atomic.rs` places on a line. The font-dependent keywords +/// (`sub`, `super`, `text-top`, `text-bottom`) land in [`Other`](VerticalAlign::Other), +/// the same narrowing [`Position`] applies to `sticky`: this build has no font metrics +/// to resolve them against, and the inline-atomic corpus never generates one. +/// +/// [`Length`](VerticalAlign::Length) carries both the `` and the `` +/// form, positive raising the box above the parent's baseline; a percentage is of the +/// box's own `line-height`, which `crates/layout/src/fragment.rs` resolves before the +/// value reaches a candidate. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum VerticalAlign { + Baseline, + Top, + Bottom, + Middle, + Length(LengthPercentage), + Other, +} + +/// The computed `vertical-align`. This build's Stylo models the property as CSS Inline +/// 3's shorthand over `alignment-baseline`/`baseline-shift`/`baseline-source` rather than +/// as one CSS 2.1 value, so the two longhands that carry the CSS 2.1 keywords are read +/// back and recombined here: `middle` is an `alignment-baseline`, `top` and `bottom` are +/// `baseline-shift` keywords, and a `` or `` is a `baseline-shift` +/// length. `baseline-source` carries `first`/`last`, which CSS 2.1 has no counterpart for +/// and this function does not read. +/// +/// A shift of exactly zero beside an `alignment-baseline` of `baseline` is the initial +/// value, which is `vertical-align: baseline`. +pub fn vertical_align(document: &Document, node: NodeId) -> VerticalAlign { + document.computed_style(node).map_or(VerticalAlign::Baseline, |style| { + match style.clone_baseline_shift() { + StyloBaselineShift::Keyword(StyloBaselineShiftKeyword::Top) => return VerticalAlign::Top, + StyloBaselineShift::Keyword(StyloBaselineShiftKeyword::Bottom) => return VerticalAlign::Bottom, + StyloBaselineShift::Keyword(StyloBaselineShiftKeyword::Center) => return VerticalAlign::Middle, + StyloBaselineShift::Keyword(_) => return VerticalAlign::Other, + StyloBaselineShift::Length(lp) => match LengthPercentage::from_stylo(&lp) { + LengthPercentage::Length(px) if px == 0.0 => {} + resolved => return VerticalAlign::Length(resolved), + }, + } + match to_css_string(&style.clone_alignment_baseline()).as_str() { + "baseline" => VerticalAlign::Baseline, + "middle" => VerticalAlign::Middle, + _ => VerticalAlign::Other, + } + }) +} + +/// The computed `line-height` in CSS pixels, or `None` for `normal` -- the one value +/// whose used height comes from the font rather than from the cascade, and the one +/// `crates/layout/src/inline_atomic.rs` puts out of scope. A `` has already been +/// multiplied by the element's own `font-size` by the time the value computes, and so has +/// a ``, so both arrive here as a plain length. +pub fn line_height(document: &Document, node: NodeId) -> Option { + document.computed_style(node).and_then(|style| match style.clone_line_height() { + StyloLineHeight::Normal => None, + StyloLineHeight::Length(px) => Some(f64::from(px.0.px())), + StyloLineHeight::Number(n) => Some(f64::from(n.0) * f64::from(style.clone_font_size().used_size().px())), + }) +} + +/// CSS 2.1 §16.2's `text-align`, narrowed to the three values +/// `crates/layout/src/inline_atomic.rs` distributes a line box's leftover space by. +/// `justify` and everything else land in [`Other`](TextAlign::Other). The initial +/// computed value is `start`, which in the left-to-right corpus this build measures is +/// [`Left`](TextAlign::Left) -- the same narrowing [`Float`] applies to `inline-start`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextAlign { + Left, + Right, + Center, + Other, +} + +pub fn text_align(document: &Document, node: NodeId) -> TextAlign { + document.computed_style(node).map_or(TextAlign::Left, |style| match style.clone_text_align() { + StyloTextAlign::Start | StyloTextAlign::Left | StyloTextAlign::MozLeft => TextAlign::Left, + StyloTextAlign::End | StyloTextAlign::Right | StyloTextAlign::MozRight => TextAlign::Right, + StyloTextAlign::Center | StyloTextAlign::MozCenter => TextAlign::Center, + StyloTextAlign::Justify => TextAlign::Other, + }) +} + fn to_css_string(value: &T) -> String { let mut s = String::new(); value.to_css(&mut CssWriter::new(&mut s)).expect("ToCss writing to a String never fails"); @@ -885,6 +1024,100 @@ mod tests { ); } + #[test] + fn display_reports_the_four_keywords_box_construction_branches_on() { + let html = "\ +
\ +
"; + let document = Document::parse(html); + assert_eq!(display(&document, find(&document, "a")), Display::None); + assert_eq!(display(&document, find(&document, "b")), Display::FlowRoot); + assert_eq!(display(&document, find(&document, "c")), Display::InlineBlock); + assert_eq!(display(&document, find(&document, "d")), Display::Block); + assert_eq!(display(&document, find(&document, "e")), Display::Other, "inline is out of scope"); + assert_eq!(display(&document, find(&document, "f")), Display::Other); + } + + /// The two older accessors keep answering exactly as they did; [`display`] is an + /// addition beside them, not a replacement (its own doc comment). + #[test] + fn display_agrees_with_the_two_boolean_accessors_it_was_added_beside() { + let html = "\ +
"; + let document = Document::parse(html); + for id in ["a", "b", "c"] { + let node = find(&document, id); + assert_eq!(is_display_none(&document, node), display(&document, node) == Display::None); + assert_eq!(is_display_flow_root(&document, node), display(&document, node) == Display::FlowRoot); + } + } + + #[test] + fn only_inline_block_is_inline_level() { + assert!(Display::InlineBlock.is_inline_level()); + assert!(!Display::Block.is_inline_level()); + assert!(!Display::FlowRoot.is_inline_level()); + assert!(!Display::None.is_inline_level()); + assert!(!Display::Other.is_inline_level()); + } + + #[test] + fn vertical_align_reports_the_css21_keywords_and_lengths() { + let html = "\ +
\ +
"; + let document = Document::parse(html); + let va = |id| vertical_align(&document, find(&document, id)); + assert_eq!(va("a"), VerticalAlign::Baseline); + assert_eq!(va("b"), VerticalAlign::Top); + assert_eq!(va("c"), VerticalAlign::Bottom); + assert_eq!(va("d"), VerticalAlign::Middle); + assert_eq!(va("e"), VerticalAlign::Length(LengthPercentage::Length(12.0))); + assert_eq!(va("f"), VerticalAlign::Length(LengthPercentage::Length(-4.0)), "negative lowers the box"); + assert_eq!(va("g"), VerticalAlign::Length(LengthPercentage::Percentage(0.5))); + assert_eq!(va("h"), VerticalAlign::Other, "sub needs font metrics and is out of scope"); + } + + #[test] + fn vertical_align_defaults_to_baseline() { + let document = Document::parse("
"); + assert_eq!(vertical_align(&document, find(&document, "a")), VerticalAlign::Baseline); + } + + #[test] + fn line_height_is_a_length_unless_it_is_normal() { + let html = "\ +
"; + let document = Document::parse(html); + assert_eq!(line_height(&document, find(&document, "a")), Some(20.0)); + assert_eq!(line_height(&document, find(&document, "b")), None, "normal reads the font"); + assert_eq!(line_height(&document, find(&document, "c")), Some(15.0), "a number multiplies font-size"); + assert_eq!( + line_height(&document, find(&document, "d")), + Some(24.0), + "an explicit length is independent of a zero font-size, which is what the corpus relies on" + ); + } + + #[test] + fn text_align_narrows_to_the_three_values_a_line_box_distributes_by() { + let html = "\ +
"; + let document = Document::parse(html); + assert_eq!(text_align(&document, find(&document, "a")), TextAlign::Left); + assert_eq!(text_align(&document, find(&document, "b")), TextAlign::Right); + assert_eq!(text_align(&document, find(&document, "c")), TextAlign::Center); + assert_eq!(text_align(&document, find(&document, "d")), TextAlign::Other, "justify is out of scope"); + assert_eq!(text_align(&document, find(&document, "e")), TextAlign::Left, "start, in a ltr corpus"); + } + #[test] fn overflow_accessors_report_the_computed_keyword_and_default_to_visible() { let html = "\ diff --git a/crates/generation/fixtures/inline-atomic-candidates/aligns_every_bottom_edge.rs b/crates/generation/fixtures/inline-atomic-candidates/aligns_every_bottom_edge.rs new file mode 100644 index 00000000..1c01c7a3 --- /dev/null +++ b/crates/generation/fixtures/inline-atomic-candidates/aligns_every_bottom_edge.rs @@ -0,0 +1,96 @@ +//! Seeded-wrong candidate: line breaking, line stacking, the strut and `text-align` are +//! all right, but every box is aligned by putting its bottom margin edge on the line's +//! baseline, whatever its `vertical-align` says and whatever its own baseline is. +//! +//! That is the right answer for a plain `vertical-align: baseline` box with no line box +//! inside it, because CSS 2.1 10.8.1 puts such a box's baseline at its bottom margin edge +//! -- so this candidate is right on most of the corpus and wrong exactly where the +//! alignment or the inner baseline matters: the `AlignTop`, `AlignBottom`, `AlignMiddle`, +//! `AlignLength`, `AlignPercentage` and `NestedInlineBlocks` categories in +//! `crates/harness/src/inline_atomic_cases.rs`. If the generation loop admits it, the +//! corpus cannot tell 10.8.1's alignment rules from their absence and needs more cases, +//! not a weaker fixture (`specs/inline-atomic.md`). + +use dom::computed::{TextAlign, VerticalAlign}; +use layout::inline_atomic::{InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox}; + +/// The indices of `inlines` that go on each line box, in order. CSS 2.1 9.4.2: fill a +/// line left to right and start a new one when the next box would take it past the +/// content width; a box wider than the whole line goes alone on its own and overflows. +fn break_into_lines(inputs: &InlineAtomicInputs) -> Vec> { + let mut lines: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut used = 0.0; + for (i, inline) in inputs.inlines.iter().enumerate() { + if !current.is_empty() && used + inline.margin_box_width > inputs.content_width { + lines.push(std::mem::take(&mut current)); + used = 0.0; + } + current.push(i); + used += inline.margin_box_width; + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// Where a line's leftover horizontal space goes (CSS 2.1 9.4.2's `text-align`): the +/// offset the first box on the line starts at. +fn line_start(inputs: &InlineAtomicInputs, indices: &[usize]) -> f64 { + let used: f64 = indices.iter().map(|&i| inputs.inlines[i].margin_box_width).sum(); + let leftover = (inputs.content_width - used).max(0.0); + match inputs.text_align { + TextAlign::Right => leftover, + TextAlign::Center => leftover / 2.0, + TextAlign::Left | TextAlign::Other => 0.0, + } +} + +pub fn resolve(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + let mut placements = vec![InlinePlacement { x: 0.0, y: 0.0 }; inputs.inlines.len()]; + let mut lines: Vec = Vec::new(); + let mut top = 0.0; + + for indices in break_into_lines(inputs) { + // The strut: half the line height above the baseline and half below it, the + // container's font being zero-sized. + let mut above = inputs.line_height / 2.0; + // Nothing ever reaches below the baseline: every box's bottom edge is pinned to + // it, so the strut's own descent is all there is under the line's baseline. + let below = inputs.line_height / 2.0; + for &i in &indices { + // THE MISTAKE: the box's bottom margin edge goes on the baseline, so its top + // sits a full margin-box height above it -- `vertical_align` is never read + // and `inline.baseline` is never used. + above = above.max(inputs.inlines[i].margin_box_height); + } + let height = above + below; + + let mut x = line_start(inputs, &indices); + for &i in &indices { + let inline = &inputs.inlines[i]; + placements[i] = InlinePlacement { x, y: top + above - inline.margin_box_height }; + x += inline.margin_box_width; + } + lines.push(LineBox { top, height, baseline: above }); + top += height; + } + + // Referenced so the enum stays exhaustively handled somewhere in this candidate even + // though the alignment itself is skipped: a variant added to `VerticalAlign` should + // break this fixture's build rather than silently change what it is wrong about. + if let Some(first) = inputs.inlines.first() { + match first.vertical_align { + VerticalAlign::Baseline + | VerticalAlign::Top + | VerticalAlign::Bottom + | VerticalAlign::Middle + | VerticalAlign::Length(_) + | VerticalAlign::Other => {} + } + } + + let last_baseline = lines.last().map(|line| line.top + line.baseline); + InlineAtomicOutputs { placements, lines, total_height: top, last_baseline } +} diff --git a/crates/generation/fixtures/inline-atomic-candidates/baseline.rs b/crates/generation/fixtures/inline-atomic-candidates/baseline.rs new file mode 100644 index 00000000..85305346 --- /dev/null +++ b/crates/generation/fixtures/inline-atomic-candidates/baseline.rs @@ -0,0 +1,14 @@ +//! Baseline candidate: the deliberately wrong rule this function is seeded with +//! (`specs/inline-atomic.md`) -- every atomic inline on a line of its own at the +//! container's left content edge, `vertical-align`, `text-align` and the strut all +//! ignored, which is what the engine did for a `display: inline-block` box before the +//! function existed. Delegates to `layout::inline_atomic::resolve_baseline` rather than +//! reimplementing it, so the generation loop's baseline and the shipped baseline can +//! never drift from each other by accident (mirrors +//! `crates/generation/fixtures/transform-candidates/baseline.rs`). + +pub fn resolve( + inputs: &layout::inline_atomic::InlineAtomicInputs, +) -> layout::inline_atomic::InlineAtomicOutputs { + layout::inline_atomic::resolve_baseline(inputs) +} diff --git a/crates/generation/fixtures/inline-atomic-candidates/never_breaks_a_line.rs b/crates/generation/fixtures/inline-atomic-candidates/never_breaks_a_line.rs new file mode 100644 index 00000000..8743c176 --- /dev/null +++ b/crates/generation/fixtures/inline-atomic-candidates/never_breaks_a_line.rs @@ -0,0 +1,109 @@ +//! Seeded-wrong candidate: the strut, `vertical-align` in all six of its forms and +//! `text-align` are all right, but a line is never broken -- every atomic inline in the +//! container goes on one line box, however far past the content width it runs. +//! +//! A container whose boxes do fit across it comes out identical to the correct answer, so +//! this candidate is right on most of the corpus and wrong exactly where the line breaks: +//! the `LineBreak`, `WiderThanContainer` and `TextAlignCenter` categories in +//! `crates/harness/src/inline_atomic_cases.rs` (the last of those is built two lines' +//! worth deep for this reason -- its leftover space differs per line). If the generation +//! loop admits it, the corpus cannot tell line breaking from its absence and needs more +//! cases, not a weaker fixture (`specs/inline-atomic.md`). + +use dom::computed::{LengthPercentage, TextAlign, VerticalAlign}; +use layout::inline_atomic::{ + AtomicInline, InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox, +}; + +/// Where a line's leftover horizontal space goes (CSS 2.1 9.4.2's `text-align`): the +/// offset the first box on the line starts at. +fn line_start(inputs: &InlineAtomicInputs, indices: &[usize]) -> f64 { + let used: f64 = indices.iter().map(|&i| inputs.inlines[i].margin_box_width).sum(); + let leftover = (inputs.content_width - used).max(0.0); + match inputs.text_align { + TextAlign::Right => leftover, + TextAlign::Center => leftover / 2.0, + TextAlign::Left | TextAlign::Other => 0.0, + } +} + +/// How far the box's margin-box top sits from the line's baseline, positive downward, for +/// the alignments CSS 2.1 10.8.1 measures against the baseline rather than against the +/// line box. `None` for `top` and `bottom`, which cannot be placed until the line's own +/// extent is known. +fn top_relative_to_baseline(inline: &AtomicInline) -> Option { + match inline.vertical_align { + // The box's own baseline meets the line's. + VerticalAlign::Baseline | VerticalAlign::Other => Some(-inline.baseline), + // Raised by the shift, so its top moves up by that much again. A percentage has + // already been resolved into a length before it gets here. + VerticalAlign::Length(LengthPercentage::Length(px)) => Some(-inline.baseline - px), + VerticalAlign::Length(LengthPercentage::Percentage(_)) + | VerticalAlign::Length(LengthPercentage::Unresolved) => Some(-inline.baseline), + // The box's vertical midpoint meets the baseline; the parent's x-height is zero + // over a zero-size font, so there is nothing to add to it. + VerticalAlign::Middle => Some(-inline.margin_box_height / 2.0), + VerticalAlign::Top | VerticalAlign::Bottom => None, + } +} + +pub fn resolve(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + if inputs.inlines.is_empty() { + return InlineAtomicOutputs { + placements: Vec::new(), + lines: Vec::new(), + total_height: 0.0, + last_baseline: None, + }; + } + + // THE MISTAKE: one line box for every box in the container, whatever + // `inputs.content_width` says. The correct rule starts a new line box as soon as the + // next box would take the line past that width. + let indices: Vec = (0..inputs.inlines.len()).collect(); + + // The strut is half the line height above the baseline and half below it. + let mut above = inputs.line_height / 2.0; + let mut below = inputs.line_height / 2.0; + for &i in &indices { + let inline = &inputs.inlines[i]; + if let Some(top_rel) = top_relative_to_baseline(inline) { + above = above.max(-top_rel); + below = below.max(top_rel + inline.margin_box_height); + } + } + // A `top` box hangs from the line's top edge and a `bottom` box from its bottom, so + // either can only make the line taller, never move the baseline within what is + // already there. CSS 2.1 10.8 asks for the smallest line box that fits them: grow + // downward for a `top` box and upward for a `bottom` one. + for &i in &indices { + let inline = &inputs.inlines[i]; + let height = above + below; + match inline.vertical_align { + VerticalAlign::Top if inline.margin_box_height > height => { + below += inline.margin_box_height - height; + } + VerticalAlign::Bottom if inline.margin_box_height > height => { + above += inline.margin_box_height - height; + } + _ => {} + } + } + let height = above + below; + + let mut placements = vec![InlinePlacement { x: 0.0, y: 0.0 }; inputs.inlines.len()]; + let mut x = line_start(inputs, &indices); + for &i in &indices { + let inline = &inputs.inlines[i]; + let y = match inline.vertical_align { + VerticalAlign::Top => 0.0, + VerticalAlign::Bottom => height - inline.margin_box_height, + _ => above + top_relative_to_baseline(inline).unwrap_or(0.0), + }; + placements[i] = InlinePlacement { x, y }; + x += inline.margin_box_width; + } + + let lines = vec![LineBox { top: 0.0, height, baseline: above }]; + InlineAtomicOutputs { placements, lines, total_height: height, last_baseline: Some(above) } +} diff --git a/crates/generation/src/absolute_runner.rs b/crates/generation/src/absolute_runner.rs index a063dbd2..669f7006 100644 --- a/crates/generation/src/absolute_runner.rs +++ b/crates/generation/src/absolute_runner.rs @@ -56,6 +56,10 @@ pub fn candidate_dump(html: &str, resolver: AbsoluteResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/admit.rs b/crates/generation/src/admit.rs index cb08fb0d..a5c787aa 100644 --- a/crates/generation/src/admit.rs +++ b/crates/generation/src/admit.rs @@ -9,8 +9,9 @@ use std::path::Path; use anyhow::Result; use provenance::{ - closure_for, closure_hash, css21_absolute_positioning, css21_containing_block, css21_margin_collapse, - css21_percentage_resolution, css21_relative_positioning, css21_width, css_transforms_transform, + closure_for, closure_hash, css21_absolute_positioning, css21_containing_block, css21_inline_atomic, + css21_margin_collapse, css21_percentage_resolution, css21_relative_positioning, css21_width, + css_transforms_transform, normalize_spec_text, Hash, ProvenanceRecord, VerificationResult, }; @@ -359,6 +360,34 @@ pub fn record_for_transform( } } +/// [`record_for`]'s counterpart for the inline-atomic function (CSS 2.1 §9.4.2/§10.8, +/// `provenance::css21_inline_atomic`). No extra intermediate-fact check, the same as +/// [`record_for_containing_block`]: `report.admitted` is already the whole gate. +pub fn record_for_inline_atomic( + report: &CandidateReport, + model: &str, + prompt_hash: &Hash, + sampling_seed: u64, +) -> ProvenanceRecord { + let closure = closure_for(css21_inline_atomic::SECTION_ID, &css21_inline_atomic::Fixture); + ProvenanceRecord { + spec_url: css21_inline_atomic::SPEC_URL.to_string(), + section_id: css21_inline_atomic::SECTION_ID.to_string(), + spec_text_hash: Hash::of(normalize_spec_text(css21_inline_atomic::SPEC_TEXT)), + definition_closure_hash: closure_hash(&closure, &css21_inline_atomic::Fixture), + spec_level: css21_inline_atomic::SPEC_LEVEL.to_string(), + spec_revision: SPEC_REVISION.to_string(), + model: model.to_string(), + prompt_hash: prompt_hash.clone(), + sampling_seed, + verification: VerificationResult { + cases_run: report.cases_run, + cases_passed: report.cases_passed, + regression_free: report.admitted, + }, + } +} + /// Writes `record` as `.provenance.json` under `dir` /// (`docs/provenance.md` -> "Where the record lives"). pub fn write(dir: &Path, candidate_name: &str, record: &ProvenanceRecord) -> Result<()> { diff --git a/crates/generation/src/bin/feature_model_run.rs b/crates/generation/src/bin/feature_model_run.rs index e795841b..e1a4c737 100644 --- a/crates/generation/src/bin/feature_model_run.rs +++ b/crates/generation/src/bin/feature_model_run.rs @@ -502,7 +502,8 @@ fn main() -> Result<()> { | Seam::Height | Seam::Overflow | Seam::ContainingBlock - | Seam::Transform => { + | Seam::Transform + | Seam::InlineAtomic => { unreachable!("Args::parse only ever produces Seam::Reflect or Seam::Mutation") } } diff --git a/crates/generation/src/bin/inline_atomic_generation_loop.rs b/crates/generation/src/bin/inline_atomic_generation_loop.rs new file mode 100644 index 00000000..9d8caa6a --- /dev/null +++ b/crates/generation/src/bin/inline_atomic_generation_loop.rs @@ -0,0 +1,122 @@ +//! [`bin/transform_generation_loop.rs`](../transform_generation_loop.rs)'s counterpart +//! for the inline-atomic function: compiles the hand-written candidates in +//! `crates/generation/fixtures/inline-atomic-candidates` into one batch crate, runs every +//! candidate over the cached corpus, and gates each against the permanent regression set +//! (`generation::inline_atomic_runner::evaluate`). Requires +//! `inline_atomic_ground_truth_cache` to have already populated +//! `corpus/inline-atomic-ground-truth/`. +//! +//! Like the transform loop, this one never admits anything: `specs/inline-atomic.md` is +//! explicit that a correct implementation is not written by hand before every model batch +//! has failed, so there is no `correct` fixture here, only `baseline` and two +//! deliberately wrong candidates this binary proves the gate rejects. All three seed the +//! regression set (the comment at the seeding site says why the baseline alone is not +//! enough here either). + +use anyhow::{bail, Result}; +use generation::candidate_crate; +use generation::groundtruth::load_all; +use generation::inline_atomic_runner::passing_case_ids; +use generation::model::{CandidateSource, DirectoryCandidateSource}; +use generation::orchestrate::{repo_root, run_candidates, write_regression_set}; +use layout::inline_atomic::{resolve_baseline, InlineAtomicResolver}; + +fn main() -> Result<()> { + let repo_root = repo_root(); + let cache_dir = repo_root.join("corpus/inline-atomic-ground-truth"); + let cases = load_all(&cache_dir)?; + if cases.is_empty() { + bail!( + "no cached cases under {}; run the inline_atomic_ground_truth_cache binary first", + cache_dir.display() + ); + } + + let fixtures_dir = repo_root.join("crates/generation/fixtures/inline-atomic-candidates"); + let candidates = DirectoryCandidateSource { dir: fixtures_dir }.candidates()?; + println!( + "candidates: {}", + candidates.iter().map(|c| c.name.as_str()).collect::>().join(", ") + ); + + let crate_dir = repo_root.join("corpus/candidates/inline-atomic-generated"); + candidate_crate::write_for(candidate_crate::Seam::InlineAtomic, &crate_dir, &repo_root, &candidates)?; + let binary = candidate_crate::compile(&crate_dir, &crate_dir.join("target"))?; + + let regression_set_path = repo_root.join("corpus/inline-atomic-regression-set.json"); + let report_out = repo_root.join("corpus/candidates/inline-atomic-report.json"); + + // Seeded from every case any hand-written fixture passes -- the shipped (deliberately + // wrong) baseline's own and both seeded-wrong candidates' -- not the baseline's + // alone, the way `bin/transform_generation_loop.rs` and + // `bin/float_generation_loop.rs` already seed, and for the same reason. Each + // seeded-wrong fixture is wrong in one respect and right in the others, so the union + // is the set of cases known to be achievable, and a candidate wrong in any one of + // those respects loses some of it. + // + // The baseline alone would be worse here than anywhere: it puts every atomic inline + // on a line of its own at the container's left edge, which is the wrong answer for + // every case in this corpus except a container holding exactly one box wider than + // half its content width -- so a gate seeded from the baseline would have almost + // nothing to catch a wrong candidate on, and `never_breaks_a_line` (which is right + // about everything else) would sail through it. + if !regression_set_path.exists() { + let ungated = run_candidates(&binary, &cache_dir, None, &report_out)?; + let mut seed: Vec = passing_case_ids(resolve_baseline as InlineAtomicResolver, &cases); + for candidate in &ungated.candidates { + for id in &candidate.passed_case_ids { + if !seed.contains(id) { + seed.push(id.clone()); + } + } + } + write_regression_set(®ression_set_path, &seed)?; + println!( + "seeded the permanent regression set with {} case(s) at least one fixture already passes", + seed.len() + ); + } + + let gated = run_candidates(&binary, &cache_dir, Some(®ression_set_path), &report_out)?; + + println!("\n== per-candidate results =="); + for candidate in &gated.candidates { + println!( + "{:<28} {}/{} passed, admitted={}, regressed={}", + candidate.name, + candidate.cases_passed, + candidate.cases_run, + candidate.admitted, + candidate.regressed_cases.len(), + ); + let mut categories: Vec<_> = candidate.per_category.iter().collect(); + categories.sort_by_key(|(name, _)| (*name).clone()); + for (category, tally) in categories { + println!(" {category:<32} {}/{}", tally.passed, tally.total); + } + } + + // Prove the gate discriminates: every seeded-wrong candidate must not be admitted, + // given this fixture set. No `correct` candidate exists yet to also check the + // positive case with (this binary's own module doc). If one is admitted, the loop or + // the corpus is wrong, not the fixture (`specs/inline-atomic.md`). + let get = |name: &str| gated.candidates.iter().find(|c| c.name == name); + let wrong_names = ["aligns_every_bottom_edge", "never_breaks_a_line"]; + let mut discrimination_ok = true; + for name in wrong_names { + match get(name) { + Some(wrong) if wrong.admitted => { + discrimination_ok = false; + eprintln!("gate did not reject seeded-wrong candidate {name}"); + } + Some(_) => {} + None => bail!("no candidate named {name:?} in the batch"), + } + } + if !discrimination_ok { + bail!("the generation loop did not reject every seeded-wrong candidate"); + } + println!("\ngate rejected every seeded-wrong candidate"); + + Ok(()) +} diff --git a/crates/generation/src/bin/inline_atomic_ground_truth_cache.rs b/crates/generation/src/bin/inline_atomic_ground_truth_cache.rs new file mode 100644 index 00000000..692eeead --- /dev/null +++ b/crates/generation/src/bin/inline_atomic_ground_truth_cache.rs @@ -0,0 +1,143 @@ +//! [`bin/transform_ground_truth_cache.rs`](../transform_ground_truth_cache.rs)'s +//! counterpart for the inline-atomic function: runs the oracle once over +//! `harness::inline_atomic_cases::enumerated`/`random`, writing each case's engine dumps +//! to `corpus/inline-atomic-ground-truth/` (gitignored, and deliberately its own +//! directory -- no other corpus sets `display: inline-block`). +//! +//! Each case is voted before it is written and dropped outright when the engines +//! disagree beyond tolerance, the same way every other cache binary does it. This +//! function needs the drop count broken down by category as well, and for a reason of +//! its own: every container here sets `font-size: 0` so the strut's ascent and descent +//! are zero and no case depends on a font (`crates/harness/src/inline_atomic_cases.rs`'s +//! own module doc). If an engine treats a zero-size font's strut differently from the +//! other two, the cases that turn on the strut -- `StrutTallerThanContent`, +//! `AlignMiddle`, `BaselineDifferentHeights` -- drop together and the others do not, +//! which the per-category table below shows and a bare total would hide. +//! `specs/inline-atomic.md` is explicit that more than a fifth of a category dropping is +//! a finding to record, naming the engine and what it disagreed on, never a corpus bug +//! to paper over or a reason to loosen the 1/60 px tolerance +//! (`docs/geometry-verification.md`). + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::Result; +use generation::groundtruth::CachedCase; +use harness::inline_atomic_cases; +use harness::oracle::Oracle; + +/// Mirrors `bin/transform_ground_truth_cache.rs`'s own constants: modest, so the cache +/// stays cheap to rebuild on one machine. +const RANDOM_SAMPLE_SEED: u64 = 0xABC0FFEE; +const RANDOM_SAMPLE_COUNT: usize = 96; + +/// How many cases each category offered and how many survived the vote. +#[derive(Default, Clone, Copy)] +struct CategoryDrops { + offered: usize, + dropped: usize, +} + +fn main() -> Result<()> { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crate sits two levels below the repository root") + .to_path_buf(); + let out_dir = repo_root.join("corpus/inline-atomic-ground-truth"); + + let mut oracle = Oracle::start(&repo_root, None, None)?; + println!("engines: {}", oracle.engines.join(", ")); + + let mut cases = inline_atomic_cases::enumerated(); + cases.extend(inline_atomic_cases::random(RANDOM_SAMPLE_SEED, RANDOM_SAMPLE_COUNT)); + + let mut written = 0usize; + let mut dropped = 0usize; + let mut per_category: BTreeMap = BTreeMap::new(); + for case in &cases { + // The corpus's own text-free claim, checked once more against the markup that is + // actually about to be measured rather than only in the case file's tests: a + // text node between two atomic inlines would generate an inline box needing a + // font, which is exactly what this corpus is built to keep out. + assert!( + inline_atomic_cases::no_text_between_atomic_inlines(&case.html), + "{}: a text node reached the markup, which would make the measurement font-dependent", + case.id + ); + let categories: Vec = case.categories.iter().map(|c| format!("{c:?}")).collect(); + for category in &categories { + per_category.entry(category.clone()).or_default().offered += 1; + } + let drop_case = |per_category: &mut BTreeMap| { + for category in &categories { + per_category.entry(category.clone()).or_default().dropped += 1; + } + }; + + let (dumps, errors) = oracle.dump(&case.html)?; + for (engine, msg) in &errors { + eprintln!("{}: {engine} failed: {msg}", case.id); + } + let cached = CachedCase { + case_id: case.id.clone(), + seed: case.seed, + categories: categories.clone(), + html: case.html.clone(), + dumps: dumps.into_iter().collect(), + errors: errors.into_iter().collect(), + }; + + match cached.vote() { + Ok(vote_report) => { + let disagreements: Vec<_> = vote_report.untrustworthy().collect(); + if !disagreements.is_empty() { + eprintln!( + "{}: dropped, engines split on {} measurement(s) beyond tolerance", + case.id, + disagreements.len() + ); + drop_case(&mut per_category); + dropped += 1; + continue; + } + } + Err(e) => { + eprintln!("{}: dropped, no reliable vote ({e:?})", case.id); + drop_case(&mut per_category); + dropped += 1; + continue; + } + } + + cached.write(&out_dir)?; + written += 1; + } + + println!("wrote {written} case(s) to {}, dropped {dropped} for engine disagreement", out_dir.display()); + println!("\n== per-category drops =="); + let mut heavy: Vec<&String> = Vec::new(); + for (category, drops) in &per_category { + println!( + "{category:<32} {}/{} kept, {} dropped", + drops.offered - drops.dropped, + drops.offered, + drops.dropped + ); + if drops.offered > 0 && drops.dropped * 5 > drops.offered { + heavy.push(category); + } + } + if !heavy.is_empty() { + println!( + "\nmore than a fifth of these categories dropped: {}", + heavy.iter().map(|c| c.as_str()).collect::>().join(", ") + ); + println!( + "specs/inline-atomic.md asks for which engine disagreed and on what to be recorded \ + before the loop goes on; the per-case lines above name the split measurements" + ); + } + oracle.shutdown()?; + Ok(()) +} diff --git a/crates/generation/src/bin/inline_atomic_real_model_run.rs b/crates/generation/src/bin/inline_atomic_real_model_run.rs new file mode 100644 index 00000000..b0882a17 --- /dev/null +++ b/crates/generation/src/bin/inline_atomic_real_model_run.rs @@ -0,0 +1,643 @@ +//! [`bin/transform_real_model_run.rs`](../transform_real_model_run.rs)'s +//! counterpart for the inline-atomic function: draws candidates from the model +//! gateway, probes, compiles and scores each model's share as soon as that model +//! finishes, and admits the best one across every share against the permanent +//! regression set. Requires `inline_atomic_ground_truth_cache` and +//! `inline_atomic_generation_loop` to have run first, for the cached corpus and the +//! regression set respectively. +//! +//! Model list, provider and the stop condition are `specs/inline-atomic.md`'s: +//! `NEV_PROVIDER` unset so the gateway is used, `NEV_MODELS` naming each alias with +//! its own candidate count and concurrency, and the loop stopping on two flat +//! batches or after six, whichever comes first. `NEV_PROVIDER=claude-code` is the +//! fallback only if the gateway does not answer, and never `anthropic`. +//! +//! Per-model counts are printed the way `bin/overflow_real_model_run.rs` prints +//! them (`orchestrate::print_per_model_summary`, `best_per_model`), and the winner's +//! own model -- not the joined model-list label -- is what the provenance record +//! names, through the `model_of` lookup at admission. +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Instant; + +use anyhow::Result; +use generation::groundtruth::{load_all, CachedCase}; +use generation::model::{ + AnthropicClient, Candidate, CandidateSource, ClaudeCandidateSource, ClaudeCliCandidateSource, + ClaudeCliClient, HttpModelClient, RemoteCandidateSource, +}; +use generation::orchestrate::{ + candidate_index, count_per_model, improved_any_category, load_regression_set, print_per_model_summary, repo_root, + run_candidates, share_ident, write_regression_set, +}; +use generation::runner::{CandidateReport, CategoryTally}; +use generation::{admit, candidate_crate, prompt}; +use provenance::css21_inline_atomic; + +/// Same reasoning as `bin/real_model_run.rs`'s own `sample_by_category`: one case per +/// distinct failing category, rather than every failing document, keeps the prompt +/// representative without sending all of them. +fn sample_by_category<'a>(failing: &[&'a CachedCase]) -> Vec<&'a CachedCase> { + let mut seen = HashSet::new(); + failing + .iter() + .filter(|case| case.categories.iter().any(|cat| seen.insert(cat.clone()))) + .copied() + .collect() +} + +/// How many candidates one batch draws, read from `NEV_CANDIDATES` via +/// `generation::orchestrate::candidate_count` (default 8). A model list (`NEV_MODELS`) +/// can give each model its own count instead. +fn batch_candidate_count() -> usize { + generation::orchestrate::candidate_count() +} + +/// `specs/automated-repair-iteration.md`'s own cap: "do not run more than six" batches in +/// one unattended pass, regardless of the stop condition below. +const MAX_BATCHES: u32 = 6; + +/// `specs/automated-repair-iteration.md`'s stop condition: two consecutive batches with no +/// per-category improvement (`orchestrate::improved_any_category`) end the loop before +/// `MAX_BATCHES` is reached. +const STOP_AFTER_CONSECUTIVE_NO_IMPROVEMENT: u32 = 2; + +/// `MAX_BATCHES`, or fewer when `NEV_MAX_BATCHES` asks for fewer. A value above +/// `MAX_BATCHES`, zero, or unparsable falls back to `MAX_BATCHES` with a note. +fn max_batches() -> u32 { + parse_max_batches(generation::env::var("MAX_BATCHES").ok().as_deref()) +} + +fn parse_max_batches(value: Option<&str>) -> u32 { + match value { + None => MAX_BATCHES, + Some(value) => match value.trim().parse::() { + Ok(n) if (1..=MAX_BATCHES).contains(&n) => n, + _ => { + eprintln!("NEV_MAX_BATCHES={value:?} is not between 1 and {MAX_BATCHES}, using {MAX_BATCHES}"); + MAX_BATCHES + } + }, + } +} + +enum Provider { + Local(HttpModelClient), + Claude(AnthropicClient), + ClaudeCli(ClaudeCliClient), +} + +/// The paths a batch works against, fixed for the whole run. +struct RunPaths { + repo_root: PathBuf, + cache_dir: PathBuf, + regression_set_path: PathBuf, + target_dir: PathBuf, + generated_dir: PathBuf, + /// Held around every candidate-crate build (`score_share`): builds share one target + /// directory and one output binary path, and a mixed batch scores shares concurrently. + build_lock: Mutex<()>, +} + +/// One model's share of a batch -- or a whole batch from a single-model provider -- after +/// probing, compiling and scoring (`score_share`), before anything is admitted. +struct ScoredShare { + /// Every candidate drawn into this share, by name, with the model that produced it. + model_of: BTreeMap, + /// The names that compiled on their own. + compiled: Vec, + /// One report per compiled candidate that scored. + reports: Vec, +} + +/// Every share of a batch merged into one: the model of every candidate, the names that +/// compiled, and the reports in candidate order rather than share-completion order. +fn merge_shares(shares: Vec) -> ScoredShare { + let mut merged = ScoredShare { model_of: BTreeMap::new(), compiled: Vec::new(), reports: Vec::new() }; + for share in shares { + merged.model_of.extend(share.model_of); + merged.compiled.extend(share.compiled); + merged.reports.extend(share.reports); + } + merged.reports.sort_by_key(|r| candidate_index(&r.name)); + merged.compiled.sort_by_key(|name| candidate_index(name)); + merged +} + +/// The admitted candidate with the most passed cases (the earliest on a tie), and the +/// model that produced it -- the model the provenance names. `fallback_model` covers a +/// candidate `model_of` does not know, which a single-model provider never leaves. +fn pick_winner<'a>( + reports: &'a [CandidateReport], + model_of: &'a BTreeMap, + fallback_model: &'a str, +) -> Option<(&'a CandidateReport, &'a str)> { + let mut best: Option<&CandidateReport> = None; + for report in reports { + if report.admitted && best.is_none_or(|b| report.cases_passed > b.cases_passed) { + best = Some(report); + } + } + best.map(|winner| (winner, model_of.get(&winner.name).map(String::as_str).unwrap_or(fallback_model))) +} + +/// Probes each candidate on its own, compiles the survivors as one crate and scores them +/// against cached ground truth, admitting nothing -- `conclude_batch` picks the winner +/// across every share. When a batch mixes models this runs on each model's own draw +/// thread as soon as that model's last reply lands +/// (`RemoteCandidateSource::candidates_by_model`), so a fast model's share is scored while +/// a slow model is still drawing. Every candidate crate builds to the same target path, so +/// `paths.build_lock` covers each build and the copy of the scored binary out of it; +/// scoring then runs outside the lock. +fn score_share( + candidates: Vec, + share_label: &str, + batch_dir: &Path, + paths: &RunPaths, + fallback_model: &str, +) -> Result { + let sources_dir = batch_dir.join("sources"); + let replies_dir = batch_dir.join("replies"); + fs::create_dir_all(&sources_dir)?; + fs::create_dir_all(&replies_dir)?; + let model_of: BTreeMap = candidates + .iter() + .map(|c| (c.name.clone(), c.model.clone().unwrap_or_else(|| fallback_model.to_string()))) + .collect(); + for candidate in &candidates { + fs::write(sources_dir.join(format!("{}.rs", candidate.name)), &candidate.source)?; + if let Some(reply) = &candidate.reply { + fs::write(replies_dir.join(format!("{}.txt", candidate.name)), reply)?; + } + if let Some(t) = candidate.telemetry { + println!( + "{:<12} {:<22} {} prompt + {} completion = {} tokens, {} ms, {} source bytes, stop_reason={}", + candidate.name, + model_of[&candidate.name], + t.prompt_tokens, + t.completion_tokens, + t.total_tokens, + t.wall_clock_ms, + candidate.source.len(), + candidate.stop_reason.as_deref().unwrap_or("none"), + ); + } + } + + // Each candidate is probed on its own first, same reasoning as + // `bin/real_model_run.rs`: one candidate that fails to compile should not take the + // whole batch down with it. The probes build together in one cargo invocation + // (`candidate_crate::probe`), each share in its own probe workspace; every probe and + // scoring crate across every batch shares one target directory so cargo reuses the + // dependency build. The compiler's messages per candidate go to `compile-errors/`. + let probes_started = Instant::now(); + let probe_count = candidates.len(); + let ident = share_ident(share_label); + let outcomes = { + let _build = paths.build_lock.lock().expect("no share panics while building"); + candidate_crate::probe_batch( + candidate_crate::Seam::InlineAtomic, + &batch_dir.join("probe").join(&ident), + &paths.repo_root, + &candidates, + &paths.target_dir, + )? + }; + candidate_crate::write_compile_errors(&batch_dir.join("compile-errors"), &outcomes)?; + let mut surviving = Vec::new(); + for (candidate, outcome) in candidates.into_iter().zip(&outcomes) { + if outcome.compiled { + surviving.push(candidate); + } else { + println!("{}: failed to compile, dropped from the batch", candidate.name); + } + } + println!("{share_label}: {}/{} candidate(s) compiled", surviving.len(), model_of.len()); + println!( + "timing: {share_label}: probed {probe_count} candidate(s) in {:.1}s", + probes_started.elapsed().as_secs_f64() + ); + let compiled: Vec = surviving.iter().map(|c| c.name.clone()).collect(); + if surviving.is_empty() { + return Ok(ScoredShare { model_of, compiled, reports: Vec::new() }); + } + + let crate_dir = batch_dir.join(format!("generated-{ident}")); + candidate_crate::write_for(candidate_crate::Seam::InlineAtomic, &crate_dir, &paths.repo_root, &surviving)?; + let binary = crate_dir.join("candidates-scored"); + let scoring_build_started = Instant::now(); + { + let _build = paths.build_lock.lock().expect("no share panics while building"); + let built = candidate_crate::compile(&crate_dir, &paths.target_dir)?; + fs::copy(&built, &binary)?; + } + println!( + "timing: {share_label}: built the scoring crate in {:.1}s", + scoring_build_started.elapsed().as_secs_f64() + ); + let scoring_started = Instant::now(); + let report = run_candidates( + &binary, + &paths.cache_dir, + Some(&paths.regression_set_path), + &batch_dir.join(format!("report-{ident}.json")), + )?; + println!( + "timing: {share_label}: scored {} candidate(s) in {:.1}s", + compiled.len(), + scoring_started.elapsed().as_secs_f64() + ); + Ok(ScoredShare { model_of, compiled, reports: report.candidates }) +} + +/// Everything a batch does once every share is scored: report compile counts and scores +/// per model, pick the best admitted candidate across all shares, and commit it if it +/// beats what is already committed. Returns the admitted candidate's per-category tally, +/// or an empty map when nothing was admitted -- what `improved_any_category` compares +/// batch to batch. +fn conclude_batch( + shares: Vec, + requested: usize, + batch_dir: &Path, + paths: &RunPaths, + built_prompt: &prompt::Prompt, + model_label: &str, + regression_set: &mut Vec, +) -> Result> { + let sources_dir = batch_dir.join("sources"); + let ScoredShare { model_of, compiled, reports } = merge_shares(shares); + + println!("\n{}/{requested} candidate(s) compiled", compiled.len()); + let drawn_per_model = count_per_model(model_of.keys(), &model_of); + let compiled_per_model = count_per_model(compiled.iter(), &model_of); + for (model, drawn) in &drawn_per_model { + println!(" {model}: {}/{drawn} compiled", compiled_per_model.get(model).copied().unwrap_or(0)); + } + if reports.is_empty() { + println!("no candidate compiled and scored; nothing to evaluate this batch"); + return Ok(BTreeMap::new()); + } + fs::write(batch_dir.join("report.json"), serde_json::to_string_pretty(&reports)?)?; + + println!("\n== per-candidate results =="); + for candidate in &reports { + println!( + "{:<12} {:<22} {}/{} passed, admitted={}, regressed={}", + candidate.name, + model_of.get(&candidate.name).map(String::as_str).unwrap_or(model_label), + candidate.cases_passed, + candidate.cases_run, + candidate.admitted, + candidate.regressed_cases.len(), + ); + let mut categories: Vec<_> = candidate.per_category.iter().collect(); + categories.sort_by_key(|(name, _)| (*name).clone()); + for (category, tally) in categories { + println!(" {category:<32} {}/{}", tally.passed, tally.total); + } + } + + print_per_model_summary(&reports, &model_of); + + let Some((winner, winner_model)) = pick_winner(&reports, &model_of, model_label) else { + println!("\nno candidate from {model_label} was admitted this batch"); + return Ok(BTreeMap::new()); + }; + let record = admit::record_for_inline_atomic(winner, winner_model, &built_prompt.hash, prompt::HAND_WRITTEN_SEED); + let provenance_dir = batch_dir.join("provenance"); + admit::write(&provenance_dir, &winner.name, &record)?; + println!( + "\nadmitted {} from {}; wrote provenance record to {}", + winner.name, + winner_model, + provenance_dir.join(format!("{}.provenance.json", winner.name)).display() + ); + + // The engine builds from here, not from `corpus/` -- a worktree's `corpus/` does not + // survive its removal (`roadmap.md` -> "Decisions", committed candidates). Never let a + // later batch's admission overwrite a better already-committed candidate. + fs::create_dir_all(&paths.generated_dir)?; + let committed = admit::read_committed_score(&paths.generated_dir.join("inline_atomic.provenance.json")); + if admit::beats_committed(&committed, winner.cases_passed) { + let winner_source = fs::read_to_string(sources_dir.join(format!("{}.rs", winner.name)))?; + let header = format!( + "// Admitted candidate `{}` ({}, {}/{}); provenance: inline_atomic.provenance.json\n", + winner.name, winner_model, winner.cases_passed, winner.cases_run + ); + let winner_source = admit::rewrite_layout_imports(&winner_source); + fs::write(paths.generated_dir.join("inline_atomic.rs"), format!("{header}{winner_source}"))?; + admit::write(&paths.generated_dir, "inline_atomic", &record)?; + println!("committed the winner to {}", paths.generated_dir.join("inline_atomic.rs").display()); + println!( + "note: `pub mod inline_atomic;` in {} and the `layout*` entry points' default are a hand change beside this one", + paths.generated_dir.join("mod.rs").display() + ); + } else if let admit::CommittedScore::Existing { cases_passed, cases_run } = committed { + println!( + "{} scores {}/{}, not better than the committed {cases_passed}/{cases_run}; leaving {} unchanged", + winner.name, + winner.cases_passed, + winner.cases_run, + paths.generated_dir.join("inline_atomic.rs").display() + ); + } + + for id in &winner.passed_case_ids { + if !regression_set.contains(id) { + regression_set.push(id.clone()); + } + } + write_regression_set(&paths.regression_set_path, regression_set)?; + println!("regression set now covers {} case(s)", regression_set.len()); + + Ok(winner.per_category.clone()) +} + +fn main() -> Result<()> { + let repo_root = repo_root(); + let candidate_count = batch_candidate_count(); + + let (provider, model_label) = if generation::env::var("PROVIDER").as_deref() == Ok("anthropic") { + let client = match AnthropicClient::from_env() { + Ok(c) => c, + Err(e) => { + println!("no Claude credentials configured, skipping the real-model run: {e:#}"); + return Ok(()); + } + }; + println!("model: {} via Anthropic API", client.model); + let model_label = client.model.clone(); + (Provider::Claude(client), model_label) + } else if generation::env::var("PROVIDER").as_deref() == Ok("claude-code") { + let client = match ClaudeCliClient::from_env() { + Ok(c) => c, + Err(e) => { + println!("claude CLI not available, skipping the real-model run: {e:#}"); + return Ok(()); + } + }; + let model_label = format!("claude-code:{}", client.model.as_deref().unwrap_or("default")); + println!("model: {model_label} via claude CLI subprocess (subscription-billed)"); + (Provider::ClaudeCli(client), model_label) + } else { + let client = match HttpModelClient::from_env() { + Ok(c) => c, + Err(e) => { + println!("no model endpoint configured, skipping the real-model run: {e:#}"); + return Ok(()); + } + }; + match client.reachable() { + Ok(true) => {} + Ok(false) => { + println!( + "{} does not list {} among its models, skipping the real-model run", + client.base_url, client.model + ); + return Ok(()); + } + Err(e) => { + println!("{} is unreachable, skipping the real-model run: {e:#}", client.base_url); + return Ok(()); + } + } + println!("model endpoint reachable: {} ({})", client.base_url, client.model); + let model_label = client.model.clone(); + (Provider::Local(client), model_label) + }; + + let cache_dir = repo_root.join("corpus/inline-atomic-ground-truth"); + let cases = load_all(&cache_dir)?; + let regression_set_path = repo_root.join("corpus/inline-atomic-regression-set.json"); + if cases.is_empty() || !regression_set_path.exists() { + println!( + "no ground-truth cache or transform regression set yet; run inline_atomic_ground_truth_cache and inline_atomic_generation_loop first" + ); + return Ok(()); + } + let mut regression_set = load_regression_set(®ression_set_path)?; + + let run_dir = repo_root.join("corpus/candidates/inline-atomic-model-run"); + let seam_path = repo_root.join("crates/layout/src/inline_atomic.rs"); + let paths = RunPaths { + target_dir: run_dir.join("target"), + generated_dir: repo_root.join("crates/layout/src/generated"), + cache_dir, + regression_set_path, + repo_root, + build_lock: Mutex::new(()), + }; + + let loop_started = Instant::now(); + let mut previous_tally: BTreeMap = BTreeMap::new(); + let mut consecutive_no_improvement = 0u32; + let mut batches_run = 0u32; + + let max_batches = max_batches(); + for batch_num in 1..=max_batches { + // Every other generated function seeds its regression set from its own committed + // candidate's passes, so "outside the regression set" and "the engine still gets + // this wrong" mean the same thing and either can drive the prompt. Not here: + // `inline_atomic_generation_loop` seeds from the union of every fixture's passes + // (its own comment says why), and the two fixtures between them cover the whole + // corpus, so the set is complete from the first batch while the committed + // candidate -- the deliberately wrong vertical stacking -- passes none of it. + // Sampling the empty difference would draw no batch and answer nothing, the same + // dead end `bin/height_real_model_run.rs` hit from the opposite direction, so the + // prompt is sampled over the whole corpus instead. The ordinary stop condition + // still applies (unlike there, where one challenge batch is the whole question): + // a batch here can genuinely improve on the one before it. + let outstanding: Vec<_> = cases.iter().filter(|c| !regression_set.contains(&c.case_id)).collect(); + let whole_corpus = outstanding.is_empty(); + let pool: Vec<_> = if whole_corpus { cases.iter().collect() } else { outstanding }; + let sampled = sample_by_category(&pool); + if sampled.is_empty() { + println!("\nno cases in the corpus at all; nothing to draw a prompt from"); + break; + } + + batches_run = batch_num; + println!("\n===== batch {batch_num}/{max_batches} ====="); + println!( + "{} case(s) to show{}, sampled to {} (one per category)", + pool.len(), + if whole_corpus { + " (the regression set already covers the corpus, so the prompt is sampled over all of it)" + } else { + " (outside the regression set)" + }, + sampled.len() + ); + + let seam_source = fs::read_to_string(&seam_path)?; + let built_prompt = prompt::build_inline_atomic(css21_inline_atomic::SPEC_TEXT, &seam_source, &sampled); + println!("prompt: {} bytes, hash {}", built_prompt.text.len(), built_prompt.hash); + + let batch_dir = run_dir.join(format!("batch-{batch_num}")); + fs::create_dir_all(&batch_dir)?; + fs::write(batch_dir.join("prompt.txt"), &built_prompt.text)?; + + println!("\n== candidates =="); + let (shares, requested) = match &provider { + Provider::Local(client) => { + let source = RemoteCandidateSource { + client, + prompt: &built_prompt.text, + count: candidate_count, + name_prefix: "model", + }; + // Each model's share is scored on its own draw thread as soon as that + // model finishes, so a slow model in the list does not also hold back + // every other model's scoring. + let draw_started = Instant::now(); + let shares = source + .candidates_by_model(|model, candidates| { + println!( + "timing: {}: drew {} candidate(s) in {:.1}s", + model.model, + candidates.len(), + draw_started.elapsed().as_secs_f64() + ); + score_share(candidates, &model.model, &batch_dir, &paths, &model.model) + }) + .into_iter() + .collect::>>()?; + // A model list (`NEV_MODELS`) can give each model its own count, so + // the batch's size is their sum rather than `NEV_CANDIDATES`. + let requested = client.models.iter().map(|m| m.count.unwrap_or(candidate_count)).sum(); + (shares, requested) + } + Provider::Claude(client) => { + let source = ClaudeCandidateSource { + client, + prompt: &built_prompt.text, + count: candidate_count, + name_prefix: "model", + }; + let share = score_share(source.candidates()?, &model_label, &batch_dir, &paths, &model_label)?; + (vec![share], candidate_count) + } + Provider::ClaudeCli(client) => { + let source = ClaudeCliCandidateSource { + client, + prompt: &built_prompt.text, + count: candidate_count, + name_prefix: "model", + }; + let share = score_share(source.candidates()?, &model_label, &batch_dir, &paths, &model_label)?; + (vec![share], candidate_count) + } + }; + let this_tally = conclude_batch( + shares, + requested, + &batch_dir, + &paths, + &built_prompt, + &model_label, + &mut regression_set, + )?; + + if improved_any_category(&previous_tally, &this_tally) { + consecutive_no_improvement = 0; + } else { + consecutive_no_improvement += 1; + println!( + "batch {batch_num}: no per-category improvement over the previous batch ({consecutive_no_improvement}/{STOP_AFTER_CONSECUTIVE_NO_IMPROVEMENT} consecutive with none)" + ); + } + previous_tally = this_tally; + + if consecutive_no_improvement >= STOP_AFTER_CONSECUTIVE_NO_IMPROVEMENT { + println!( + "\n{consecutive_no_improvement} consecutive batches with no per-category improvement; stopping" + ); + break; + } + } + + println!("\n{batches_run} batch(es) run in {:.1}s", loop_started.elapsed().as_secs_f64()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(name: &str, cases_passed: u64, admitted: bool) -> CandidateReport { + CandidateReport { + name: name.to_string(), + cases_run: 111, + cases_passed, + admitted, + regressed_cases: Vec::new(), + per_category: BTreeMap::new(), + passed_case_ids: Vec::new(), + failing_case_diffs: Vec::new(), + } + } + + fn share(pairs: &[(&str, &str)], compiled: &[&str], reports: Vec) -> ScoredShare { + ScoredShare { + model_of: pairs.iter().map(|(n, m)| (n.to_string(), m.to_string())).collect(), + compiled: compiled.iter().map(|n| n.to_string()).collect(), + reports, + } + } + + /// The slower model's share lands second, yet the merged listing reads in draw order. + #[test] + fn merged_shares_list_reports_in_candidate_order() { + let flash = share(&[("model_0", "flash"), ("model_2", "flash")], &["model_0", "model_2"], vec![ + report("model_0", 70, true), + report("model_2", 90, true), + ]); + let fast = share(&[("model_1", "fast"), ("model_10", "fast")], &["model_10"], vec![report("model_10", 85, false)]); + let merged = merge_shares(vec![fast, flash]); + let names: Vec<_> = merged.reports.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, ["model_0", "model_2", "model_10"]); + assert_eq!(merged.compiled, ["model_0", "model_2", "model_10"]); + assert_eq!(merged.model_of.len(), 4); + } + + /// Provenance names the winner's own model, never the joined model-list label, and an + /// unadmitted candidate never wins however high it scores. + #[test] + fn the_winner_is_the_best_admitted_candidate_under_its_own_model() { + let reports = vec![report("model_0", 70, true), report("model_1", 99, false), report("model_2", 92, true)]; + let model_of: BTreeMap = [("model_0", "flash"), ("model_1", "flash"), ("model_2", "fast")] + .iter() + .map(|(n, m)| (n.to_string(), m.to_string())) + .collect(); + let (winner, model) = pick_winner(&reports, &model_of, "flash+fast").expect("an admitted candidate"); + assert_eq!(winner.name, "model_2"); + assert_eq!(model, "fast"); + } + + #[test] + fn nothing_admitted_means_no_winner() { + let reports = vec![report("model_0", 85, false)]; + assert!(pick_winner(&reports, &BTreeMap::new(), "flash").is_none()); + } + + #[test] + fn a_single_model_provider_falls_back_to_its_own_label() { + let reports = vec![report("model_0", 80, true)]; + let model_of = BTreeMap::new(); + let (_, model) = pick_winner(&reports, &model_of, "claude-code:default").expect("admitted"); + assert_eq!(model, "claude-code:default"); + } + + #[test] + fn max_batches_accepts_one_to_six_and_falls_back_otherwise() { + assert_eq!(parse_max_batches(None), 6); + assert_eq!(parse_max_batches(Some("1")), 1); + assert_eq!(parse_max_batches(Some(" 6 ")), 6); + assert_eq!(parse_max_batches(Some("0")), 6); + assert_eq!(parse_max_batches(Some("7")), 6); + assert_eq!(parse_max_batches(Some("many")), 6); + } +} diff --git a/crates/generation/src/candidate_crate.rs b/crates/generation/src/candidate_crate.rs index 85e05d41..0dcd6e16 100644 --- a/crates/generation/src/candidate_crate.rs +++ b/crates/generation/src/candidate_crate.rs @@ -62,6 +62,9 @@ pub enum Seam { /// `pub fn resolve` of type `layout::transform::TransformResolver`; /// `crate::transform_runner::main`. Transform, + /// `pub fn resolve` of type `layout::inline_atomic::InlineAtomicResolver`; + /// `crate::inline_atomic_runner::main`. + InlineAtomic, } impl Seam { @@ -77,7 +80,8 @@ impl Seam { | Seam::Height | Seam::Overflow | Seam::ContainingBlock - | Seam::Transform => { + | Seam::Transform + | Seam::InlineAtomic => { let f = if self == Seam::Mutation { "apply" } else { "resolve" }; format!(" (\"{name}\", candidates::{name}::{f}),\n") } @@ -128,6 +132,10 @@ impl Seam { Seam::Transform => { ("(&str, layout::transform::TransformResolver)", "generation::transform_runner::main") } + Seam::InlineAtomic => ( + "(&str, layout::inline_atomic::InlineAtomicResolver)", + "generation::inline_atomic_runner::main", + ), } } } @@ -652,4 +660,13 @@ mod tests { )); assert!(height.contains("generation::height_runner::main(registry)")); } + + #[test] + fn inline_atomic_registers_its_own_function_and_runner() { + let inline_atomic = main_rs_for(Seam::InlineAtomic); + assert!(inline_atomic.contains( + "let registry: &[(&str, layout::inline_atomic::InlineAtomicResolver)] = &[\n (\"c\", candidates::c::resolve),\n" + )); + assert!(inline_atomic.contains("generation::inline_atomic_runner::main(registry)")); + } } diff --git a/crates/generation/src/containing_block_runner.rs b/crates/generation/src/containing_block_runner.rs index 40fab7de..7240303a 100644 --- a/crates/generation/src/containing_block_runner.rs +++ b/crates/generation/src/containing_block_runner.rs @@ -56,6 +56,10 @@ pub fn candidate_dump(html: &str, resolver: ContainingBlockResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/float_runner.rs b/crates/generation/src/float_runner.rs index c4b7e4ec..822aa77e 100644 --- a/crates/generation/src/float_runner.rs +++ b/crates/generation/src/float_runner.rs @@ -53,6 +53,10 @@ pub fn candidate_dump(html: &str, resolver: FloatResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/height_runner.rs b/crates/generation/src/height_runner.rs index 49e5ed46..6e948d00 100644 --- a/crates/generation/src/height_runner.rs +++ b/crates/generation/src/height_runner.rs @@ -70,6 +70,10 @@ pub fn candidate_dump(html: &str, resolver: HeightResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/inline_atomic_runner.rs b/crates/generation/src/inline_atomic_runner.rs new file mode 100644 index 00000000..24807602 --- /dev/null +++ b/crates/generation/src/inline_atomic_runner.rs @@ -0,0 +1,445 @@ +//! [`crate::transform_runner`]'s counterpart for the inline-atomic function +//! (`crates/layout/src/inline_atomic.rs`, `specs/inline-atomic.md`). Structurally +//! identical: same scoring, same regression-set admission gate, no extra +//! intermediate-fact check the way `crate::margin_runner` adds one against +//! `crates/logic`'s rules -- there is no analogous rules engine for line boxes to +//! cross-check against, so geometry-regression-free is this function's whole admission +//! gate too. +//! +//! Every other function runs at its own admitted candidate rather than its baseline, the +//! same choice `crate::transform_runner` makes and for the same reason: this corpus's +//! documents give every box an explicit width and height, but the container's own width, +//! its padding and border resolution and its auto height are still decided by the width, +//! percentage and height functions, and a case must not fail because one of those got +//! the geometry around the line boxes wrong. Only where the atomic inlines land is under +//! test. + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use anyhow::{bail, Context, Result}; +use dom::Document; +use harness::compare::{ + compare, relative_offset, CaseComparison, CaseReport, Consensus, Metric, VoteMeasurement, +}; +use harness::dump::{Dump, Node as DumpNode, Rect, Viewport, TOLERANCE_PX}; +use layout::boxtree::BoxTree; +use layout::fragment; +use layout::inline_atomic::InlineAtomicResolver; + +use crate::groundtruth::CachedCase; +use crate::runner::{CandidateReport, CaseGeometryDiff, CategoryTally, Report}; + +/// The harness's fixed viewport, matching `crate::runner::VIEWPORT_WIDTH`. +const VIEWPORT_WIDTH: f64 = 800.0; +const VIEWPORT_HEIGHT: f64 = 600.0; + +/// Renders `html` through the real layout path with every other function held at its own +/// admitted candidate (this module's own doc comment) and `resolver` standing in for the +/// inline-atomic function. Mirrors `crate::transform_runner::candidate_dump`, generalized +/// over which resolver runs. +pub fn candidate_dump(html: &str, resolver: InlineAtomicResolver) -> Dump { + let document = Document::parse(html); + let elements = document.elements_in_document_order(); + let tree = BoxTree::build(&document); + let root_fragment = fragment::layout_with_resolvers( + &document, + &tree, + (VIEWPORT_WIDTH, VIEWPORT_HEIGHT), + layout::generated_width_resolve, + layout::generated_margin_classify, + layout::generated_margin_combine, + layout::generated_percentage_resolve, + layout::generated_absolute_resolve, + layout::generated_float_resolve, + layout::generated_relpos_resolve, + layout::generated_height_resolve, + layout::generated_containing_block_resolve, + layout::generated_transform_resolve, + resolver, + ); + + let mut rects: BTreeMap = BTreeMap::new(); + root_fragment.walk_absolute(0.0, 0.0, &mut |node, x, y, w, h| { + rects.insert(node, Rect { x, y, w, h }); + }); + + let mut index_of: BTreeMap = BTreeMap::new(); + for (i, &el) in elements.iter().enumerate() { + index_of.insert(el, i); + } + + let nodes = elements + .iter() + .enumerate() + .map(|(index, el)| { + let parent = el.dom_parent().map(|p| index_of[&p] as i64).unwrap_or(-1); + let mut depth = 0u32; + let mut cur = *el; + while let Some(p) = cur.dom_parent() { + depth += 1; + cur = p; + } + let rect = rects.get(el).copied(); + DumpNode { + index, + parent, + tag: el.tag(), + depth, + bounding: rect.unwrap_or(Rect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }), + rects: rect.map(|r| vec![r]).unwrap_or_default(), + scroll: None, + style: Default::default(), + } + }) + .collect(); + + Dump { + engine: "candidate".to_string(), + engine_version: "0".to_string(), + viewport: Viewport { width: 800, height: 600 }, + overflowed: false, + nodes, + } +} + +fn majority_value(m: &VoteMeasurement) -> Option { + match &m.consensus { + Consensus::Split => None, + Consensus::Unanimous => m.values.values().next().copied(), + Consensus::Majority { outlier } => { + m.values.iter().find(|(engine, _)| *engine != outlier).map(|(_, v)| *v) + } + } +} + +fn candidate_value(candidate: &Dump, m: &VoteMeasurement) -> Option { + let node = candidate.nodes.get(m.node_index)?; + Some(match m.metric { + Metric::Width => node.bounding.w, + Metric::Height => node.bounding.h, + Metric::OffsetX => relative_offset(candidate, node).0, + Metric::OffsetY => relative_offset(candidate, node).1, + Metric::FragmentCount => node.rects.len() as f64, + Metric::FragmentWidth(i) => node.rects.get(i)?.w, + Metric::FragmentHeight(i) => node.rects.get(i)?.h, + }) +} + +fn agrees_with_vote(candidate: &Dump, vote_report: &harness::compare::VoteReport) -> bool { + vote_report.measurements.iter().filter(|m| m.is_trustworthy()).all(|m| { + let Some(reference) = majority_value(m) else { return true }; + match candidate_value(candidate, m) { + Some(v) => (v - reference).abs() <= TOLERANCE_PX, + None => false, + } + }) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Outcome { + Passed, + Failed, + Untrustworthy, +} + +/// Scores one case and keeps the per-node numeric error alongside the verdict, the same +/// contract as `crate::runner::score_case`. The [`CaseReport`] is `None` when the case +/// never reached geometry comparison -- a discarded case, or node sequences that do not +/// line up at all. +fn score_case(case: &CachedCase, resolver: InlineAtomicResolver) -> (Outcome, Option) { + let Some(reference) = case.dumps.get("chromium").or_else(|| case.dumps.values().next()) else { + return (Outcome::Untrustworthy, None); + }; + let candidate = candidate_dump(&case.html, resolver); + + match compare(&candidate, reference) { + CaseComparison::Discarded(_) => (Outcome::Untrustworthy, None), + CaseComparison::ShapeMismatch(_) => (Outcome::Failed, None), + CaseComparison::Compared(pairwise) => { + let outcome = match case.vote() { + Ok(vote_report) => { + if agrees_with_vote(&candidate, &vote_report) { + Outcome::Passed + } else { + Outcome::Failed + } + } + Err(_) => { + if pairwise.passed { + Outcome::Passed + } else { + Outcome::Failed + } + } + }; + (outcome, Some(pairwise)) + } + } +} + +pub fn passing_case_ids(resolver: InlineAtomicResolver, cases: &[CachedCase]) -> Vec { + cases + .iter() + .filter(|case| score_case(case, resolver).0 == Outcome::Passed) + .map(|case| case.case_id.clone()) + .collect() +} + +pub fn evaluate( + registry: &[(&str, InlineAtomicResolver)], + cases: &[CachedCase], + regression_set: Option<&[String]>, +) -> Report { + let candidates = registry + .iter() + .filter_map(|(name, resolver)| { + // A candidate that panics mid-corpus would otherwise unwind out of this loop + // and take the whole batch's process down with it, losing every other + // candidate's score -- the same isolation `crate::transform_runner::evaluate` + // gives for the same reason. Easier to hit here than in most functions: a + // candidate that indexes its own `placements` by line rather than by box + // panics on the first case that breaks a line. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + score_candidate(name, *resolver, cases, regression_set) + })) + .map_err(|_| { + println!("{name}: panicked while scoring, dropped from the batch"); + }) + .ok() + }) + .collect(); + + Report { candidates } +} + +/// One candidate's pass over the whole corpus. Split out of [`evaluate`] so a panicking +/// candidate can be caught and dropped without unwinding past the others. +fn score_candidate( + name: &str, + resolver: InlineAtomicResolver, + cases: &[CachedCase], + regression_set: Option<&[String]>, +) -> CandidateReport { + let mut cases_run = 0u64; + let mut cases_passed = 0u64; + let mut passed_ids: Vec = Vec::new(); + let mut per_category: BTreeMap = BTreeMap::new(); + let mut failing_case_diffs: Vec = Vec::new(); + + for case in cases { + let (outcome, case_report) = score_case(case, resolver); + if outcome == Outcome::Untrustworthy { + continue; + } + cases_run += 1; + let passed = outcome == Outcome::Passed; + if passed { + cases_passed += 1; + passed_ids.push(case.case_id.clone()); + } else if let Some(report) = &case_report { + failing_case_diffs.push(CaseGeometryDiff::from_case_report(&case.case_id, report)); + } + for category in &case.categories { + let tally = per_category.entry(category.clone()).or_default(); + tally.total += 1; + if passed { + tally.passed += 1; + } + } + } + + let regressed_cases: Vec = regression_set + .unwrap_or(&[]) + .iter() + .filter(|id| !passed_ids.contains(id)) + .cloned() + .collect(); + + CandidateReport { + name: name.to_string(), + cases_run, + cases_passed, + admitted: regressed_cases.is_empty(), + regressed_cases, + per_category, + passed_case_ids: passed_ids, + failing_case_diffs, + } +} + +/// Entry point a generated inline-atomic candidate crate's `main.rs` calls +/// (`crate::candidate_crate::write_for` with `Seam::InlineAtomic`), the same +/// environment-variable contract `crate::transform_runner::main` uses. +pub fn main(registry: &[(&str, InlineAtomicResolver)]) -> Result<()> { + let cache_dir = PathBuf::from( + std::env::var("GENERATION_CACHE_DIR") + .context("GENERATION_CACHE_DIR must name the ground-truth cache directory")?, + ); + let cases = crate::groundtruth::load_all(&cache_dir)?; + if cases.is_empty() { + bail!("no cached cases under {}; run the ground_truth_cache binary first", cache_dir.display()); + } + + let regression_set: Option> = match std::env::var("GENERATION_REGRESSION_SET") { + Ok(path) => { + let json = fs::read_to_string(&path).with_context(|| format!("reading {path}"))?; + Some(serde_json::from_str(&json).with_context(|| format!("parsing {path}"))?) + } + Err(_) => None, + }; + + let report = evaluate(registry, &cases, regression_set.as_deref()); + let json = serde_json::to_string_pretty(&report)?; + if let Ok(out) = std::env::var("GENERATION_REPORT_OUT") { + fs::write(&out, &json).with_context(|| format!("writing {out}"))?; + } + println!("{json}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use layout::inline_atomic::{ + InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox, + }; + + /// A one-line, left-aligned, baseline-only implementation: enough to place the + /// `OneLineEqualHeights` recipe correctly, used only to check this runner's own + /// scoring plumbing. It never breaks a line and ignores `vertical-align` and + /// `text-align`. Not registered anywhere the generation loop reads. + fn resolve_one_line(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + let tallest = inputs.inlines.iter().map(|i| i.margin_box_height).fold(0.0, f64::max); + let height = tallest.max(inputs.line_height); + let mut x = 0.0; + let mut placements = Vec::with_capacity(inputs.inlines.len()); + for inline in &inputs.inlines { + placements.push(InlinePlacement { x, y: height - inline.margin_box_height }); + x += inline.margin_box_width; + } + let lines = if inputs.inlines.is_empty() { + Vec::new() + } else { + vec![LineBox { top: 0.0, height, baseline: height }] + }; + let last_baseline = lines.last().map(|line| line.top + line.baseline); + let total_height = if lines.is_empty() { 0.0 } else { height }; + InlineAtomicOutputs { placements, lines, total_height, last_baseline } + } + + const RESET: &str = "* { margin: 0; padding: 0; border: 0; box-sizing: content-box; }\nhtml, body { display: block; }\n"; + + fn find_index(html: &str, class: &str) -> usize { + let document = Document::parse(html); + document + .elements_in_document_order() + .into_iter() + .enumerate() + .find(|(_, e)| document.nodes()[e.index()].as_element().unwrap().attr("class") == Some(class)) + .map(|(i, _)| i) + .unwrap_or_else(|| panic!("no element with class={class:?}")) + } + + fn one_line_document() -> String { + format!( + "
" + ) + } + + #[test] + fn the_baseline_stacks_two_atomic_inlines_vertically() { + let html = one_line_document(); + let dump = candidate_dump(&html, layout::inline_atomic::resolve_baseline); + let a = &dump.nodes[find_index(&html, "a")]; + let b = &dump.nodes[find_index(&html, "b")]; + assert_eq!(relative_offset(&dump, a), (0.0, 0.0)); + assert_eq!(relative_offset(&dump, b), (0.0, 30.0), "under the first box, not beside it"); + } + + #[test] + fn a_candidate_that_builds_a_line_puts_them_side_by_side() { + let html = one_line_document(); + let dump = candidate_dump(&html, resolve_one_line); + let a = &dump.nodes[find_index(&html, "a")]; + let b = &dump.nodes[find_index(&html, "b")]; + assert_eq!(relative_offset(&dump, a), (0.0, 0.0)); + assert_eq!(relative_offset(&dump, b), (60.0, 0.0), "beside the first box, on the same line"); + assert_eq!((b.bounding.w, b.bounding.h), (60.0, 30.0), "placing a box never resizes it"); + } + + /// The container's auto height is the resolver's total height (CSS 2.1 10.6.3), so + /// which resolver runs changes the container's own geometry too, not just its + /// children's. + fn container_height(html: &str, resolver: InlineAtomicResolver) -> f64 { + candidate_dump(html, resolver).nodes[find_index(html, "c")].bounding.h + } + + #[test] + fn the_containers_auto_height_follows_the_resolvers_total() { + let html = one_line_document(); + assert_eq!(container_height(&html, layout::inline_atomic::resolve_baseline), 60.0, "two stacked boxes"); + assert_eq!(container_height(&html, resolve_one_line), 30.0, "one line box, as tall as its content"); + } + + /// A box on a line is still laid out as a block formatting context root inside, so + /// its own children stack exactly as they would anywhere else. + #[test] + fn an_atomic_inlines_own_children_still_stack() { + let html = format!( + "
" + ); + let dump = candidate_dump(&html, resolve_one_line); + let x = &dump.nodes[find_index(&html, "x")]; + let y = &dump.nodes[find_index(&html, "y")]; + assert_eq!(relative_offset(&dump, x), (0.0, 0.0)); + assert_eq!(relative_offset(&dump, y), (0.0, 20.0), "stacked, not put on a line of their own"); + } + + /// One candidate panicking partway through the corpus must not lose the other + /// candidates' scores -- `evaluate` catches and drops it, the rest of the batch + /// still runs and reports normally. + #[test] + fn a_panicking_candidate_is_dropped_and_the_rest_of_the_batch_still_scores() { + fn resolve_panics(_inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + panic!("candidate reached an input shape it cannot handle") + } + + let html = one_line_document(); + let reference = candidate_dump(&html, resolve_one_line); + let case = CachedCase { + case_id: "panic-isolation".to_string(), + seed: 0, + categories: vec!["OneLineEqualHeights".to_string()], + html, + dumps: BTreeMap::from([("chromium".to_string(), reference)]), + errors: BTreeMap::new(), + }; + + // Swap in a silent panic hook so the deliberate panic below doesn't print. + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let report = evaluate( + &[ + ("panicking", resolve_panics as InlineAtomicResolver), + ("one_line", resolve_one_line as InlineAtomicResolver), + ], + &[case], + None, + ); + std::panic::set_hook(previous_hook); + + assert_eq!(report.candidates.len(), 1, "the panicking candidate is dropped, not just failed"); + assert_eq!(report.candidates[0].name, "one_line", "the surviving candidate is the one that didn't panic"); + assert_eq!(report.candidates[0].cases_run, 1, "the surviving candidate still scores the one case"); + } +} diff --git a/crates/generation/src/lib.rs b/crates/generation/src/lib.rs index b2999245..3228c1e1 100644 --- a/crates/generation/src/lib.rs +++ b/crates/generation/src/lib.rs @@ -19,6 +19,7 @@ pub mod feature_runner; pub mod float_runner; pub mod groundtruth; pub mod height_runner; +pub mod inline_atomic_runner; pub mod margin_runner; pub mod model; pub mod orchestrate; diff --git a/crates/generation/src/margin_runner.rs b/crates/generation/src/margin_runner.rs index 5f8af4e5..099fc810 100644 --- a/crates/generation/src/margin_runner.rs +++ b/crates/generation/src/margin_runner.rs @@ -74,6 +74,10 @@ pub fn candidate_dump(html: &str, candidate: MarginCandidate) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/percentage_runner.rs b/crates/generation/src/percentage_runner.rs index 0a368706..b7d4381b 100644 --- a/crates/generation/src/percentage_runner.rs +++ b/crates/generation/src/percentage_runner.rs @@ -55,6 +55,10 @@ pub fn candidate_dump(html: &str, resolver: PercentageResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/prompt.rs b/crates/generation/src/prompt.rs index 03554567..8fdacf0e 100644 --- a/crates/generation/src/prompt.rs +++ b/crates/generation/src/prompt.rs @@ -950,6 +950,100 @@ const TRANSFORM_SEAM_CONTRACT: &str = "\n\n# Seam contract\n\n\ `transform` to anything but `none`, with a list of any length, and must return a \ matrix for every one of them rather than panicking or returning a placeholder.\n"; +const INLINE_ATOMIC_SEAM_CONTRACT: &str = "\n\n# Seam contract\n\n\ + Write exactly these two import lines, and no local `mod`, `struct`, `enum` or \ + `type` that redefines any of the names they bring in:\n\n\ + ```rust\n\ + use layout::inline_atomic::{AtomicInline, InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox};\n\ + use dom::computed::{LengthPercentage, TextAlign, VerticalAlign};\n\ + ```\n\n\ + Do not redefine any of those types locally. A local redefinition compiles on its \ + own but no longer matches `layout::inline_atomic`'s `InlineAtomicResolver` \ + function-pointer type, so the candidate crate fails to build. Import them from the \ + two lines above instead.\n\n\ + The only function this seam calls is `resolve`, with the exact signature shown \ + above; name it `resolve`, not anything else. `layout::inline_atomic` also exports \ + `baseline_shift`, a helper that turns a `VerticalAlign` into the plain shift in \ + pixels it stands for; `resolve` may use it or match the enum itself.\n\n\ + `VerticalAlign` has exactly six variants: `Baseline`, `Top`, `Bottom`, `Middle`, \ + `Length(LengthPercentage)` and `Other`. Every match on it must be exhaustive over \ + all six. The percentage form is already resolved before it reaches you, so a \ + `Length` always carries `LengthPercentage::Length(px)`; treat \ + `LengthPercentage::Percentage` and `LengthPercentage::Unresolved` as zero if you \ + match them at all. `Other` stands for a font-dependent keyword (`sub`, `super`, \ + `text-top`, `text-bottom`) the corpus never produces; treat it as `Baseline`. \ + `TextAlign` has exactly four variants: `Left`, `Right`, `Center` and `Other`; \ + `Other` is `justify`, which the corpus never produces -- treat it as `Left`.\n\n\ + What the inputs mean. `content_width` is the container's content box, which is how \ + wide every line box is. `line_height` is the strut's height: CSS 2.1 10.8 starts \ + every line box with a zero-width inline box carrying the container's font and \ + `line-height`, so no line box is ever shorter than this, and the strut's own \ + baseline sits at its MIDDLE -- exactly `line_height / 2` below the line box's top \ + when nothing pushes the line taller. (The corpus sets `font-size: 0` on every \ + container, which is why the strut's ascent and descent are both zero and the \ + midpoint is exact rather than font-dependent.) `text_align` distributes whatever \ + horizontal space a line has left over. `inlines` is every atomic inline in document \ + order: each one's `margin_box_width` and `margin_box_height` are the box's full \ + margin box (10.8 uses the margin box as an inline-block's height in a line box), \ + and `baseline` is that box's own baseline as a distance DOWN from its margin-box \ + top, already decided for you by 10.8.1.\n\n\ + What the outputs mean. `placements[i]` is where `inlines[i]`'s MARGIN BOX goes, as \ + an offset from the container's content-box origin -- one entry per input, in the \ + same order, so the two vectors always have the same length. `lines` is the line \ + boxes top to bottom: `top` from the container's content-box top, `height`, and \ + `baseline` as a distance down from that line's own `top`. `total_height` is the \ + whole stack's height, which becomes the container's content height when its \ + `height` is `auto` (10.6.3). `last_baseline` is the last line box's baseline \ + measured from the container's content-box top -- `top + baseline` of the last line \ + -- and `None` when there is no line box at all.\n\n\ + The rules, in the order they apply. Fill a line left to right, adding each box's \ + `margin_box_width`; when the next box would take the line past `content_width`, \ + start a new line box below and put it there instead. A box wider than \ + `content_width` goes alone on its own line and overflows it -- an atomic inline is \ + never split (9.4.2). A line box's height is the distance from the topmost box top \ + to the bottommost box bottom once every box on it is aligned, and never less than \ + `line_height`. Line boxes stack with no gap and never overlap, so each one's `top` \ + is the previous one's `top + height`.\n\n\ + Vertical alignment within a line, per 10.8.1. `Baseline` puts the box's own \ + `baseline` on the line's baseline. `Length(px)` does the same, then RAISES the box \ + by `px` (a negative value lowers it). `Middle` puts the box's vertical midpoint on \ + the line's baseline (the parent's x-height is zero here, so there is nothing to add \ + to it). `Top` puts the box's top edge on the line box's top edge, and `Bottom` puts \ + its bottom edge on the line box's bottom edge -- neither depends on the baseline, \ + which is what makes them circular with the line's own height: solve the \ + baseline-relative boxes and the strut first to get the line's extent, then place the \ + `Top` and `Bottom` boxes inside it, then grow the line if one of them still does not \ + fit, exactly as 10.8 says to minimize the line box height.\n\n\ + `resolve` is a total function of its inputs: it is called for every container whose \ + in-flow children are all `display: inline-block`, with any number of them, and must \ + return a placement for every one of them rather than panicking, indexing out of \ + bounds, or returning a short vector.\n"; + +/// [`build`]'s counterpart for the inline-atomic function (`layout::inline_atomic`). +pub fn build_inline_atomic(spec_text: &str, seam_source: &str, failing_cases: &[&CachedCase]) -> Prompt { + let normalized_spec = normalize_spec_text(spec_text); + + let mut text = String::new(); + text.push_str( + "# Spec text: CSS 2.1 9.4.2 inline formatting contexts, 10.8 and 10.8.1 line height calculations\n\n", + ); + text.push_str(&normalized_spec); + text.push_str("\n\n# Implement this exact signature\n\n"); + text.push_str(&layout_seam_as_a_candidate_sees_it(seam_source)); + text.push_str(INLINE_ATOMIC_SEAM_CONTRACT); + text.push_str( + "\n\n# Currently failing cases\n\n\ + Not shrunk to a minimal reproducer -- shrinking is not built yet \ + (roadmap.md -> \"Test input distribution\"). Each case's full document follows.\n\n", + ); + for case in failing_cases { + text.push_str(&format!("## {}\n\n```html\n{}\n```\n\n", case.case_id, case.html)); + } + + let hash = Hash::of(&text); + Prompt { text, hash } +} + /// [`build`]'s counterpart for the transform function (`layout::transform`). pub fn build_transform(spec_text: &str, seam_source: &str, failing_cases: &[&CachedCase]) -> Prompt { let normalized_spec = normalize_spec_text(spec_text); diff --git a/crates/generation/src/relpos_runner.rs b/crates/generation/src/relpos_runner.rs index 06dd69fc..2e25f35a 100644 --- a/crates/generation/src/relpos_runner.rs +++ b/crates/generation/src/relpos_runner.rs @@ -59,6 +59,10 @@ pub fn candidate_dump(html: &str, resolver: RelposResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/transform_runner.rs b/crates/generation/src/transform_runner.rs index d160c71e..9e4cd78a 100644 --- a/crates/generation/src/transform_runner.rs +++ b/crates/generation/src/transform_runner.rs @@ -55,6 +55,10 @@ pub fn candidate_dump(html: &str, resolver: TransformResolver) -> Dump { layout::generated_height_resolve, layout::generated_containing_block_resolve, resolver, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/harness/src/inline_atomic_cases.rs b/crates/harness/src/inline_atomic_cases.rs new file mode 100644 index 00000000..eee5b894 --- /dev/null +++ b/crates/harness/src/inline_atomic_cases.rs @@ -0,0 +1,577 @@ +//! Stratified generation of line-box test cases over atomic inlines, `crate::cases`'s +//! counterpart for the inline-atomic function (`specs/inline-atomic.md`). Reuses +//! `crate::cases`'s document-assembly machinery (`Elem`, `Source`, `px`, `document`, +//! `RESET`) rather than duplicating it, but keeps its own `Case`/`Category`/`make_case` +//! for the same reason `crate::absolute_cases` and `crate::transform_cases` do: +//! `crate::cases::Case::categories` is typed over a different domain than this corpus's +//! own [`InlineAtomicCategory`]. **Do not add to `crate::cases`'s own `RECIPES`** -- this +//! corpus's recipes live here, entirely separate from the seeded-random draw the other +//! tracks measure against. +//! +//! **Text-free by construction, and font-free by choice.** `crate::cases`'s `document()` +//! and `write_body()` emit nested `
`s with no whitespace between tags, so no text +//! node can ever appear between two atomic inlines; [`no_text_between_atomic_inlines`] +//! asserts that for every case this module produces. That leaves one way a font could +//! still reach the measurement: CSS 2.1 §10.8 starts every line box with a strut, a +//! zero-width inline box carrying the container's own font and `line-height`, whose +//! ascent and descent place the baseline inside the line height. So every inline +//! formatting context container here sets `font-size: 0` beside an explicit +//! `line-height` in px. With a zero-size font the strut's ascent and descent are both +//! zero, the half-leading is split equally above and below an empty content area, the +//! baseline sits exactly at the middle of the line height, and `vertical-align: middle` +//! -- which aligns on the parent's baseline plus half its x-height -- aligns on the +//! baseline itself. No case here depends on a font's metrics. +//! +//! That choice holds until `text-metrics-oracle` has measured whether the three engines +//! agree on real text. Until then a corpus that used a real font would be measuring the +//! engines' font stacks as much as their line-box arithmetic, and a case that failed the +//! three-engine vote would not say which. +//! +//! Every atomic inline has an explicit `width` and `height`: a `width: auto` +//! inline-block shrink-wraps its contents, which is `min-max-content`'s rule and not +//! this function's (`crates/layout/src/inline_atomic.rs`'s own "Out" list). Container +//! widths are kept well inside the 800x600 viewport so no document overflows it and +//! brings a scrollbar with it, which would change every containing block's width and +//! confound the measurement with a rule this corpus is not about. + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +use crate::cases::{self, Elem, Source}; + +/// A spec case category this corpus exercises, drawn from `specs/inline-atomic.md`'s own +/// category list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InlineAtomicCategory { + /// One line of equal-height atomic inlines: the simplest thing a line box does, and + /// the case the vertical-stacking baseline gets most obviously wrong. + OneLineEqualHeights, + /// Atomic inlines of different heights, all `vertical-align: baseline`: each box's + /// own baseline -- its bottom margin edge, having no line box inside it -- lands on + /// the line's, so the shorter boxes hang down from the taller one's bottom. + BaselineDifferentHeights, + /// `vertical-align: top`: the box's top edge meets the line box's top. + AlignTop, + /// `vertical-align: bottom`: the box's bottom edge meets the line box's bottom. + AlignBottom, + /// `vertical-align: middle`: the box's vertical midpoint meets the parent's + /// baseline, which with a zero-size font is the middle of the strut. + AlignMiddle, + /// `vertical-align: `: the box is raised above the baseline by that much, + /// or lowered when it is negative. + AlignLength, + /// `vertical-align: ` of the box's own `line-height`, which is the one + /// percentage here that resolves against a property rather than a box dimension. + AlignPercentage, + /// More atomic inlines than fit across the container: the row breaks onto a second + /// line box, which is stacked directly under the first. + LineBreak, + /// One atomic inline wider than the container's content box: it goes alone on its + /// own line and overflows it rather than being broken. + WiderThanContainer, + /// `text-align: right` on the container: the leftover space on each line goes to + /// the left end. + TextAlignRight, + /// `text-align: center`: the leftover space is split equally between the two ends. + TextAlignCenter, + /// Margins, padding and borders on the atomic inlines, which are what separate two + /// boxes on a line (§9.4.2) and what make the margin box taller than the border box. + EdgesOnAtomicInlines, + /// A `line-height` taller than every atomic inline on the line: the strut, not the + /// content, decides the line box's height. + StrutTallerThanContent, + /// An atomic inline with `overflow: hidden`: §10.8.1 puts its baseline at its bottom + /// margin edge whatever is inside it, which is what separates the two halves of the + /// inline-block baseline rule. + OverflowHiddenBaseline, + /// An inline-block that is itself an inline formatting context container: its own + /// baseline is its last line box's, so the boxes on the outer line align against + /// something computed inside it. + NestedInlineBlocks, +} + +impl InlineAtomicCategory { + /// Every category the generators are expected to cover + /// (`crate::cases::Category::ALL`'s own role, mirrored here). + pub const ALL: &'static [InlineAtomicCategory] = &[ + InlineAtomicCategory::OneLineEqualHeights, + InlineAtomicCategory::BaselineDifferentHeights, + InlineAtomicCategory::AlignTop, + InlineAtomicCategory::AlignBottom, + InlineAtomicCategory::AlignMiddle, + InlineAtomicCategory::AlignLength, + InlineAtomicCategory::AlignPercentage, + InlineAtomicCategory::LineBreak, + InlineAtomicCategory::WiderThanContainer, + InlineAtomicCategory::TextAlignRight, + InlineAtomicCategory::TextAlignCenter, + InlineAtomicCategory::EdgesOnAtomicInlines, + InlineAtomicCategory::StrutTallerThanContent, + InlineAtomicCategory::OverflowHiddenBaseline, + InlineAtomicCategory::NestedInlineBlocks, + ]; +} + +/// One generated test case -- `crate::cases::Case`'s counterpart, typed over +/// [`InlineAtomicCategory`] instead (this module's own doc comment explains why the two +/// `Case` types are not the same one). +#[derive(Debug, Clone)] +pub struct Case { + pub id: String, + pub html: String, + pub seed: u64, + pub categories: Vec, + pub element_count: usize, +} + +fn make_case(id: &str, seed: u64, categories: &[InlineAtomicCategory], root: Elem) -> Case { + let element_count = root.count(); + Case { id: id.to_string(), html: cases::document(root), seed, categories: categories.to_vec(), element_count } +} + +/// The declarations every inline formatting context container in this corpus carries: +/// an explicit content width, a zero font size and an explicit `line-height` in px, so +/// the strut's baseline sits at the middle of a known height and no font is consulted +/// (this module's own doc comment). +fn container(width: f64, line_height: f64, extra: Vec<(&'static str, String)>) -> Vec<(&'static str, String)> { + let mut decls = vec![ + ("width", cases::px(width)), + ("font-size", "0".into()), + ("line-height", cases::px(line_height)), + ]; + decls.extend(extra); + decls +} + +/// One atomic inline at an explicit size -- the only shape this function places +/// (this module's own doc comment). +fn atomic(width: f64, height: f64, extra: Vec<(&'static str, String)>) -> Elem { + let mut decls = vec![ + ("display", "inline-block".into()), + ("width", cases::px(width)), + ("height", cases::px(height)), + ]; + decls.extend(extra); + Elem::new(decls) +} + +type Recipe = for<'a> fn(&mut Source<'a>) -> (Elem, Vec, &'static str); + +fn recipe_one_line_equal_heights(src: &mut Source) -> (Elem, Vec, &'static str) { + let width = src.px(&[60.0, 80.0, 45.0]); + let height = src.px(&[30.0, 48.0, 22.0]); + let line_height = src.px(&[20.0, 36.0, 12.0]); + let boxes = vec![ + atomic(width, height, Vec::new()), + atomic(width, height, Vec::new()), + atomic(width, height, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::OneLineEqualHeights], "inline-atomic-one-line-equal-heights") +} + +fn recipe_baseline_different_heights(src: &mut Source) -> (Elem, Vec, &'static str) { + let tall = src.px(&[70.0, 90.0, 55.0]); + let middling = src.px(&[40.0, 52.0, 33.0]); + let short = src.px(&[15.0, 24.0, 9.0]); + let line_height = src.px(&[20.0, 30.0, 10.0]); + let boxes = vec![ + atomic(50.0, tall, Vec::new()), + atomic(50.0, middling, Vec::new()), + atomic(50.0, short, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::BaselineDifferentHeights], "inline-atomic-baseline-different-heights") +} + +/// `top` and `bottom` are measured against a line box whose height some *other* box +/// decides, so each of these recipes keeps one tall `baseline` box beside the aligned +/// one -- otherwise the aligned box would be the tallest thing on the line and every +/// alignment would land in the same place. +fn recipe_align_top(src: &mut Source) -> (Elem, Vec, &'static str) { + let anchor = src.px(&[80.0, 100.0, 64.0]); + let aligned = src.px(&[30.0, 44.0, 18.0]); + let line_height = src.px(&[20.0, 28.0, 14.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic(60.0, aligned, vec![("vertical-align", "top".into())]), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignTop], "inline-atomic-align-top") +} + +fn recipe_align_bottom(src: &mut Source) -> (Elem, Vec, &'static str) { + let anchor = src.px(&[80.0, 100.0, 64.0]); + let aligned = src.px(&[30.0, 44.0, 18.0]); + let line_height = src.px(&[20.0, 28.0, 14.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic(60.0, aligned, vec![("vertical-align", "bottom".into())]), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignBottom], "inline-atomic-align-bottom") +} + +fn recipe_align_middle(src: &mut Source) -> (Elem, Vec, &'static str) { + let anchor = src.px(&[80.0, 100.0, 64.0]); + let aligned = src.px(&[30.0, 44.0, 18.0]); + let line_height = src.px(&[24.0, 40.0, 16.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic(60.0, aligned, vec![("vertical-align", "middle".into())]), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignMiddle], "inline-atomic-align-middle") +} + +fn recipe_align_length(src: &mut Source) -> (Elem, Vec, &'static str) { + let shift = src.px(&[12.0, 25.0, -8.0]); + let anchor = src.px(&[70.0, 88.0, 56.0]); + let aligned = src.px(&[30.0, 42.0, 20.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic(60.0, aligned, vec![("vertical-align", cases::px(shift))]), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignLength], "inline-atomic-align-length") +} + +fn recipe_align_percentage(src: &mut Source) -> (Elem, Vec, &'static str) { + // The percentage is of the *aligned box's own* `line-height`, not the container's, + // so the box carries an explicit one of its own and the two differ: a candidate that + // resolves against the container's would land somewhere else. + let pct = src.pct(&["50%", "100%", "-25%"]); + let own_line_height = src.px(&[32.0, 48.0, 20.0]); + let anchor = src.px(&[70.0, 88.0, 56.0]); + let aligned = src.px(&[30.0, 42.0, 20.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic( + 60.0, + aligned, + vec![("vertical-align", pct), ("line-height", cases::px(own_line_height))], + ), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, 20.0, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignPercentage], "inline-atomic-align-percentage") +} + +fn recipe_line_break(src: &mut Source) -> (Elem, Vec, &'static str) { + // Five boxes of this width never fit across 300px, so the row always breaks; the + // widths are chosen so the break falls after a different box in each draw. + let width = src.px(&[90.0, 70.0, 110.0]); + let height = src.px(&[25.0, 40.0, 16.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let boxes: Vec = (0..5).map(|_| atomic(width, height, Vec::new())).collect(); + let root = Elem::with_children(container(300.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::LineBreak], "inline-atomic-line-break") +} + +fn recipe_wider_than_container(src: &mut Source) -> (Elem, Vec, &'static str) { + // The wide box cannot be broken, so it goes alone on its own line and overflows it + // (§9.4.2). The two narrow boxes around it show which line each one lands on. + let wide = src.px(&[360.0, 420.0, 310.0]); + let narrow = src.px(&[60.0, 90.0, 40.0]); + let height = src.px(&[25.0, 38.0, 18.0]); + let line_height = src.px(&[20.0, 28.0, 14.0]); + let boxes = vec![ + atomic(narrow, height, Vec::new()), + atomic(wide, height, Vec::new()), + atomic(narrow, height, Vec::new()), + ]; + let root = Elem::with_children(container(300.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::WiderThanContainer], "inline-atomic-wider-than-container") +} + +fn recipe_text_align_right(src: &mut Source) -> (Elem, Vec, &'static str) { + let width = src.px(&[70.0, 55.0, 95.0]); + let height = src.px(&[30.0, 44.0, 20.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let boxes: Vec = (0..3).map(|_| atomic(width, height, Vec::new())).collect(); + let root = Elem::with_children( + container(400.0, line_height, vec![("text-align", "right".into())]), + boxes, + ); + (root, vec![InlineAtomicCategory::TextAlignRight], "inline-atomic-text-align-right") +} + +fn recipe_text_align_center(src: &mut Source) -> (Elem, Vec, &'static str) { + // Two lines' worth, so the leftover space differs per line and a candidate that + // centres the whole block rather than each line lands wrong on at least one. + let width = src.px(&[90.0, 75.0, 110.0]); + let height = src.px(&[30.0, 44.0, 20.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let boxes: Vec = (0..5).map(|_| atomic(width, height, Vec::new())).collect(); + let root = Elem::with_children( + container(300.0, line_height, vec![("text-align", "center".into())]), + boxes, + ); + (root, vec![InlineAtomicCategory::TextAlignCenter], "inline-atomic-text-align-center") +} + +fn recipe_edges_on_atomic_inlines(src: &mut Source) -> (Elem, Vec, &'static str) { + let margin = src.px(&[10.0, 18.0, 4.0]); + let padding = src.px(&[6.0, 12.0, 2.0]); + let border = src.px(&[3.0, 5.0, 1.0]); + let height = src.px(&[28.0, 40.0, 18.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let edged = |extra_height: f64| { + atomic( + 50.0, + extra_height, + vec![ + ("margin", cases::px(margin)), + ("padding", cases::px(padding)), + ("border-width", cases::px(border)), + ("border-style", "solid".into()), + ], + ) + }; + let boxes = vec![edged(height), edged(height + 20.0), edged(height)]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::EdgesOnAtomicInlines], "inline-atomic-edges") +} + +fn recipe_strut_taller_than_content(src: &mut Source) -> (Elem, Vec, &'static str) { + // Every box is shorter than the line height, so the strut alone decides the line + // box's height and the container's own auto height with it. + let line_height = src.px(&[80.0, 120.0, 60.0]); + let height = src.px(&[12.0, 20.0, 6.0]); + let width = src.px(&[60.0, 80.0, 45.0]); + let boxes: Vec = (0..3).map(|_| atomic(width, height, Vec::new())).collect(); + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::StrutTallerThanContent], "inline-atomic-strut-taller") +} + +fn recipe_overflow_hidden_baseline(src: &mut Source) -> (Elem, Vec, &'static str) { + // The two inline-blocks hold the same inner content; one clips and one does not, so + // §10.8.1's two halves land them at different heights on the same line. Without the + // inner line box there would be nothing for the `visible` one to align on and the + // pair would prove nothing. + let inner_height = src.px(&[20.0, 34.0, 12.0]); + let outer_height = src.px(&[60.0, 80.0, 48.0]); + let line_height = src.px(&[20.0, 30.0, 14.0]); + let holder = |overflow: Option<&'static str>| { + let mut decls = vec![ + ("display", "inline-block".into()), + ("width", cases::px(100.0)), + ("height", cases::px(outer_height)), + ("font-size", "0".into()), + ("line-height", cases::px(line_height)), + ]; + if let Some(value) = overflow { + decls.push(("overflow", value.into())); + } + Elem::with_children(decls, vec![atomic(40.0, inner_height, Vec::new())]) + }; + let boxes = vec![holder(None), holder(Some("hidden")), atomic(50.0, 90.0, Vec::new())]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::OverflowHiddenBaseline], "inline-atomic-overflow-hidden-baseline") +} + +fn recipe_nested_inline_blocks(src: &mut Source) -> (Elem, Vec, &'static str) { + // The outer inline-block's own baseline is its last line box's (§10.8.1), so where + // the two inner boxes sit decides where the whole outer box sits on the outer line. + let inner_a = src.px(&[24.0, 40.0, 14.0]); + let inner_b = src.px(&[48.0, 30.0, 60.0]); + let outer_line_height = src.px(&[20.0, 32.0, 12.0]); + let inner_line_height = src.px(&[16.0, 28.0, 10.0]); + let nested = Elem::with_children( + vec![ + ("display", "inline-block".into()), + ("width", cases::px(140.0)), + ("height", cases::px(90.0)), + ("font-size", "0".into()), + ("line-height", cases::px(inner_line_height)), + ], + vec![atomic(50.0, inner_a, Vec::new()), atomic(50.0, inner_b, Vec::new())], + ); + let boxes = vec![atomic(60.0, 70.0, Vec::new()), nested, atomic(60.0, 35.0, Vec::new())]; + let root = Elem::with_children(container(500.0, outer_line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::NestedInlineBlocks], "inline-atomic-nested-inline-blocks") +} + +const RECIPES: &[Recipe] = &[ + recipe_one_line_equal_heights, + recipe_baseline_different_heights, + recipe_align_top, + recipe_align_bottom, + recipe_align_middle, + recipe_align_length, + recipe_align_percentage, + recipe_line_break, + recipe_wider_than_container, + recipe_text_align_right, + recipe_text_align_center, + recipe_edges_on_atomic_inlines, + recipe_strut_taller_than_content, + recipe_overflow_hidden_baseline, + recipe_nested_inline_blocks, +]; + +/// The deterministic, reproducible set built directly from the case analysis -- one case +/// per recipe, mirroring `crate::cases::enumerated`. +pub fn enumerated() -> Vec { + RECIPES + .iter() + .map(|recipe| { + let mut src = Source::Fixed; + let (root, categories, name) = recipe(&mut src); + make_case(name, 0, &categories, root) + }) + .collect() +} + +/// `count` cases sampled over the same grammar as [`enumerated`], mirroring +/// `crate::cases::random`. +pub fn random(seed: u64, count: usize) -> Vec { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + (0..count) + .map(|i| { + let recipe = RECIPES[rng.gen_range(0..RECIPES.len())]; + let mut src = Source::Random(&mut rng); + let (root, categories, name) = recipe(&mut src); + let id = format!("random-inline-atomic-{seed:016x}-{i:04}-{name}"); + make_case(&id, seed, &categories, root) + }) + .collect() +} + +/// Whether `html` puts anything but a tag between two atomic inlines. `document()` +/// writes no whitespace between tags, so the only text in a case's markup is inside the +/// `\ +
"; + let document = Document::parse(html); + let tree = BoxTree::build(&document); + let (body, _) = body_box(&tree); + assert!(tree.establishes_inline_formatting_context(&document, body)); + for &child in &tree.boxes()[body.0].children { + assert!(tree.boxes()[child.0].inline_level); + } + } + + #[test] + fn a_mixed_container_establishes_none_and_neither_does_an_empty_one() { + let html = "\ +
"; + let document = Document::parse(html); + let tree = BoxTree::build(&document); + let (body, _) = body_box(&tree); + assert!( + !tree.establishes_inline_formatting_context(&document, body), + "a block-level sibling keeps the container stacking, out of this scope" + ); + + let empty = Document::parse("
"); + let empty_tree = BoxTree::build(&empty); + let (_, leaf) = (BoxId(0), &empty_tree.boxes()[0]); + assert!(leaf.children.is_empty(), "the innermost box is the empty div"); + assert!(!empty_tree.establishes_inline_formatting_context(&empty, BoxId(0))); + } + + /// CSS 2.1 9.7 and 9.3.1 both compute an out-of-flow box's `display` to a + /// block-level value, so neither a float nor an absolutely positioned box is + /// inline-level however it was written -- and neither keeps its container from + /// establishing an inline formatting context around the rest. + #[test] + fn an_out_of_flow_inline_block_is_not_inline_level() { + let html = "
"; + let document = Document::parse(html); + let tree = BoxTree::build(&document); + let (body, _) = body_box(&tree); + let inline_level: Vec = + tree.boxes()[body.0].children.iter().map(|c| tree.boxes()[c.0].inline_level).collect(); + assert_eq!(inline_level, vec![true, false, false]); + assert!( + !tree.establishes_inline_formatting_context(&document, body), + "a float or an absolutely positioned box beside an atomic inline is out of scope, \ + so the container keeps stacking" + ); + } } diff --git a/crates/layout/src/fragment.rs b/crates/layout/src/fragment.rs index 406d2a29..fd7fc7ca 100644 --- a/crates/layout/src/fragment.rs +++ b/crates/layout/src/fragment.rs @@ -82,6 +82,9 @@ use crate::containing_block::{ use crate::float::{self, Clear, FloatInputs, FloatResolver, PlacedFloat, Subject}; use crate::generated; use crate::height::{self, HeightInputs, HeightResolver}; +use crate::inline_atomic::{ + AtomicInline, InlineAtomicInputs, InlineAtomicResolver, InlineAtomicOutputs, +}; use crate::margin::{self, MarginClassifyResolver, MarginCombineResolver}; use crate::percentage::{self, PercentageInputs, PercentageResolver}; use crate::relpos::{RelposInputs, RelposResolver}; @@ -250,6 +253,7 @@ pub fn layout(document: &Document, tree: &BoxTree, viewport: (f64, f64)) -> Frag generated::height::resolve, generated::containing_block::resolve, generated::transform::resolve, + generated::inline_atomic::resolve, ) } @@ -281,6 +285,7 @@ pub fn layout_with_resolver( generated::height::resolve, generated::containing_block::resolve, generated::transform::resolve, + generated::inline_atomic::resolve, ) } @@ -306,6 +311,7 @@ pub fn layout_with_resolvers( height_resolver: HeightResolver, containing_block_resolver: ContainingBlockResolver, transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, ) -> Fragment { layout_box( document, @@ -336,6 +342,7 @@ pub fn layout_with_resolvers( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, Rect { x: 0.0, y: 0.0, width: viewport.0, height: viewport.1 }, None, ) @@ -439,6 +446,16 @@ struct LayoutResult { /// for a box that establishes its own context: its floats live in a context of its /// own whose frame it owns, so nothing about its position is needed to place them. needs_float_pass: bool, + /// The baseline of this box's last in-flow line box, as a distance down from its own + /// border-box top -- what CSS 2.1 10.8.1 makes this box's own baseline when it is an + /// atomic inline on someone else's line (`crate::inline_atomic`). `None` when it has + /// no in-flow line box, and `None` when its `overflow` computes to anything other + /// than `visible`, which are 10.8.1's two routes to the bottom-margin-edge fallback; + /// the caller placing this box on a line applies that fallback, since only it knows + /// where the margin edge is. `None` for every box in a document with no + /// `inline-block` in it, which is every case the corpus generated before + /// `specs/inline-atomic.md`. + last_line_baseline: Option, } /// The floats placed so far in one block formatting context, as margin boxes in the @@ -617,6 +634,12 @@ struct StackResult { /// `LayoutResult::needs_float_pass` for the box doing the stacking, before its own /// block-formatting-context status is applied. needs_float_pass: bool, + /// `LayoutResult::last_line_baseline` for the box doing the stacking, before its own + /// `overflow` is applied. `stack_children` carries its last in-flow child's through + /// (a box whose own children are block-level has no line box of its own, but one of + /// them may still end in one); `layout_inline_children` sets it from the line boxes + /// it just built. + last_line_baseline: Option, } /// Stacks `children_ids` inside a box whose own edges/margins are already known, @@ -654,6 +677,7 @@ fn stack_children( height_resolver: HeightResolver, containing_block_resolver: ContainingBlockResolver, transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, viewport: Rect, mut floats: Option>, ) -> StackResult { @@ -688,6 +712,7 @@ fn stack_children( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, None, )) @@ -738,6 +763,11 @@ fn stack_children( let mut cursor_y = padding_top + border_top; let mut placed = Vec::with_capacity(child_results.len()); let mut pending_absolute: Vec = Vec::new(); + // CSS 2.1 10.8.1's "last in-flow line box" for a box whose own children are + // block-level: it has no line box itself, but its last in-flow child may end in one, + // and that is the baseline this box hands upward if it is an atomic inline in turn. + // `None` for every box in a document with no `inline-block` in it. + let mut last_line_baseline: Option = None; for (&child_id, c) in children_ids.iter().zip(child_results) { let Some(c) = c else { @@ -923,6 +953,7 @@ fn stack_children( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, None, ); @@ -946,6 +977,7 @@ fn stack_children( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, Some(frame.nested(offset_x, offset_y)), ); @@ -996,6 +1028,9 @@ fn stack_children( placed.push(fragment); pending = c.escaping_bottom; real_child_found = true; + if let Some(b) = c.last_line_baseline { + last_line_baseline = Some(offset_y + b); + } } let escaping_top_result = if top_open { @@ -1037,6 +1072,176 @@ fn stack_children( self_collapsing, sibling_adjoin, needs_float_pass, + last_line_baseline, + } +} + +/// [`stack_children`]'s counterpart for a box that establishes an inline formatting +/// context (CSS 2.1 9.4.2, `crate::boxtree::BoxTree::establishes_inline_formatting_context`): +/// every in-flow child is an atomic inline, so they go on line boxes side by side rather +/// than stacking one under the other. Each child is laid out first, exactly the way +/// `stack_children` lays one out -- an `inline-block` establishes a block formatting +/// context for its own contents (CSS Display 3 §2.1), so its inside is ordinary block +/// layout -- and then the measurements go to `inline_atomic_resolver`, which decides +/// where on which line each one lands. Nothing here decides that: this function measures, +/// calls and places (`crate::inline_atomic`'s own module doc). +/// +/// Margins. §9.4.2 puts line boxes in the normal flow as this box's in-flow content, and +/// §8.3.1 gives a box with inline content no adjoining child margin to collapse with: an +/// atomic inline's own margins are part of its margin box on the line, never part of a +/// collapsing group. So this box's own margin-top and margin-bottom escape alone, +/// whatever `margin_classify` says about a box with block children, and an inline-block's +/// margins never collapse with anything. +#[allow(clippy::too_many_arguments)] +fn layout_inline_children( + document: &Document, + tree: &BoxTree, + id: BoxId, + node: NodeId, + children_ids: &[BoxId], + padding_left: f64, + padding_top: f64, + padding_bottom: f64, + border_left: f64, + border_top: f64, + border_bottom: f64, + own_margin_top: f64, + own_margin_bottom: f64, + establishes_bfc: bool, + height_is_auto: bool, + height_is_explicit_zero: bool, + child_available: &Available, + width_resolver: WidthResolver, + margin_classify: MarginClassifyResolver, + margin_combine: MarginCombineResolver, + percentage_resolver: PercentageResolver, + absolute_resolver: AbsoluteResolver, + float_resolver: FloatResolver, + relpos_resolver: RelposResolver, + height_resolver: HeightResolver, + containing_block_resolver: ContainingBlockResolver, + transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, + viewport: Rect, +) -> StackResult { + let mut laid_out: Vec = Vec::with_capacity(children_ids.len()); + let mut inlines: Vec = Vec::with_capacity(children_ids.len()); + for &child_id in children_ids { + let child_node = tree.boxes()[child_id.0].node; + let c = layout_box( + document, + tree, + child_id, + child_available, + width_resolver, + margin_classify, + margin_combine, + percentage_resolver, + absolute_resolver, + float_resolver, + relpos_resolver, + height_resolver, + containing_block_resolver, + transform_resolver, + inline_atomic_resolver, + viewport, + None, + ); + let margin_left = c.used_margin_left; + let margin_right = length_or_auto_zero(computed::margin_right(document, child_node)); + let margin_top = length_or_auto_zero(computed::margin_top(document, child_node)); + let margin_bottom = length_or_auto_zero(computed::margin_bottom(document, child_node)); + let margin_box_width = c.fragment.width + margin_left + margin_right; + let margin_box_height = c.fragment.height + margin_top + margin_bottom; + // CSS 2.1 10.8.1's baseline of an inline-block, measured from its margin-box top: + // its own last in-flow line box's, or -- when it has none, or its `overflow` is + // not `visible`, both of which `layout_box` has already folded into `None` -- + // its bottom margin edge. + let baseline = c.last_line_baseline.map_or(margin_box_height, |b| margin_top + b); + inlines.push(AtomicInline { + margin_box_width, + margin_box_height, + baseline, + vertical_align: resolved_vertical_align(document, child_node), + }); + laid_out.push(c); + } + + let InlineAtomicOutputs { placements, lines: _, total_height, last_baseline } = + inline_atomic_resolver(&InlineAtomicInputs { + content_width: child_available.inline_size, + // `line-height: normal` reads the font, which this function does not model + // (`crate::inline_atomic`'s own "Out" list); a zero strut is the honest + // stand-in, since a strut of no height constrains no line box. + line_height: computed::line_height(document, node).unwrap_or(0.0), + text_align: computed::text_align(document, node), + inlines, + }); + + let content_origin_x = padding_left + border_left; + let content_origin_y = padding_top + border_top; + let mut placed = Vec::with_capacity(laid_out.len()); + let mut pending_absolute: Vec = Vec::new(); + for (c, placement) in laid_out.into_iter().zip(placements) { + // The resolver places margin boxes; a fragment is a border box, one margin in + // from that -- the same split `stack_children` applies with `used_margin_left`. + let margin_left = c.used_margin_left; + let margin_top = length_or_auto_zero(computed::margin_top(document, c.fragment.node)); + let placed_x = content_origin_x + placement.x + margin_left + c.relative_shift_x; + let placed_y = content_origin_y + placement.y + margin_top + c.relative_shift_y; + let mut fragment = c.fragment; + fragment.offset_x = quantize_px(placed_x); + fragment.offset_y = quantize_px(placed_y); + for item in c.pending_absolute { + pending_absolute.push(item.translated(placed_x, placed_y)); + } + placed.push(fragment); + } + + let has_top_bp = padding_top > 0.0 || border_top > 0.0; + let has_bottom_bp = padding_bottom > 0.0 || border_bottom > 0.0; + let margin::MarginCollapseOutputs { self_collapsing: classified_self_collapsing, sibling_adjoin, .. } = + margin_classify(&margin::MarginCollapseInputs { + has_top_border_or_padding: has_top_bp, + has_bottom_border_or_padding: has_bottom_bp, + establishes_bfc, + height_is_auto, + height_is_explicit_zero, + // Line boxes are in-flow content (§9.4.2), so this box has some whenever it + // has an atomic inline to put on one -- which, by construction, it does. + no_in_flow_content: false, + }); + let self_collapsing = classified_self_collapsing && id != tree.root(); + + StackResult { + content_height_from_children: if self_collapsing { 0.0 } else { total_height.max(0.0) }, + // No child margin adjoins this box's own edges (this function's own doc comment). + escaping_top: vec![own_margin_top], + escaping_bottom: vec![own_margin_bottom], + children: placed, + pending_absolute, + self_collapsing, + sibling_adjoin, + // Floats inside an inline formatting context are out of scope + // (`crate::inline_atomic`'s own "Out" list), and the container itself never + // reads the enclosing context's float list, so no second pass is ever needed. + needs_float_pass: false, + last_line_baseline: last_baseline.map(|b| content_origin_y + b), + } +} + +/// One atomic inline's `vertical-align`, with a percentage resolved against its own +/// `line-height` the way CSS 2.1 10.8.1 defines it -- the one resolution +/// `crate::inline_atomic`'s candidates are not asked to do themselves (that module's own +/// doc comment). A percentage over a `line-height: normal` this build cannot measure +/// resolves against a zero line height, which is `baseline`. +fn resolved_vertical_align(document: &Document, node: NodeId) -> computed::VerticalAlign { + match computed::vertical_align(document, node) { + computed::VerticalAlign::Length(LengthPercentage::Percentage(fraction)) => { + let line_height = computed::line_height(document, node).unwrap_or(0.0); + computed::VerticalAlign::Length(LengthPercentage::Length(fraction * line_height)) + } + other => other, } } @@ -1052,8 +1257,10 @@ fn direction_of(document: &Document, node: NodeId) -> Direction { /// tree's root (which has nothing above it for a margin to escape into, so it is treated /// the way an `overflow: hidden` box is -- mirroring `crates/logic/src/facts.rs`'s /// synthetic root fact, checked there against live engines, -/// `docs/margin-collapse-experiment.md` -> "What was built"), a float, a `display: -/// flow-root` box, or any `overflow` other than `visible` on either axis. Decides both +/// `docs/margin-collapse-experiment.md` -> "What was built"), a float, an inline-level +/// box (9.4.1 names inline-blocks among the boxes that establish one, which is what +/// keeps an atomic inline's contents' margins from escaping onto the line it sits on), a +/// `display: flow-root` box, or any `overflow` other than `visible` on either axis. Decides both /// margin collapsing (`margin::MarginCollapseInputs::establishes_bfc`) and which box owns /// a `FloatContext`. Absolutely positioned boxes never reach this: `layout_absolute_box` /// forces the answer for them. @@ -1061,6 +1268,7 @@ fn is_bfc_root(document: &Document, tree: &BoxTree, id: BoxId) -> bool { let node = tree.boxes()[id.0].node; id == tree.root() || tree.boxes()[id.0].float.is_some() + || tree.boxes()[id.0].inline_level || computed::is_display_flow_root(document, node) || computed::overflow_x(document, node) != "visible" || computed::overflow_y(document, node) != "visible" @@ -1135,6 +1343,7 @@ fn layout_box( height_resolver: HeightResolver, containing_block_resolver: ContainingBlockResolver, transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, viewport: Rect, floats: Option>, ) -> LayoutResult { @@ -1254,37 +1463,77 @@ fn layout_box( let height_is_auto = matches!(resolved_height, LengthPercentageOrAuto::Auto); let height_is_explicit_zero = matches!(resolved_height, LengthPercentageOrAuto::Length(h) if h == 0.0); - let stacked = stack_children( - document, - tree, - id, - node, - children_ids, - padding_left, - padding_top, - padding_bottom, - border_left, - border_top, - border_bottom, - own_margin_top, - own_margin_bottom, - establishes_bfc, - height_is_auto, - height_is_explicit_zero, - &child_available, - width_resolver, - margin_classify, - margin_combine, - percentage_resolver, - absolute_resolver, - float_resolver, - relpos_resolver, - height_resolver, - containing_block_resolver, - transform_resolver, - viewport, - child_floats, - ); + // CSS 2.1 9.4.2: a box whose in-flow children are all inline-level lays them out on + // line boxes instead of stacking them (`crate::inline_atomic`), which is the one + // place this walk branches on which formatting context a box establishes for its + // contents. Every box in a document with no `inline-block` in it takes the stacking + // branch, exactly as before this function existed. + let stacked = if tree.establishes_inline_formatting_context(document, id) { + layout_inline_children( + document, + tree, + id, + node, + children_ids, + padding_left, + padding_top, + padding_bottom, + border_left, + border_top, + border_bottom, + own_margin_top, + own_margin_bottom, + establishes_bfc, + height_is_auto, + height_is_explicit_zero, + &child_available, + width_resolver, + margin_classify, + margin_combine, + percentage_resolver, + absolute_resolver, + float_resolver, + relpos_resolver, + height_resolver, + containing_block_resolver, + transform_resolver, + inline_atomic_resolver, + viewport, + ) + } else { + stack_children( + document, + tree, + id, + node, + children_ids, + padding_left, + padding_top, + padding_bottom, + border_left, + border_top, + border_bottom, + own_margin_top, + own_margin_bottom, + establishes_bfc, + height_is_auto, + height_is_explicit_zero, + &child_available, + width_resolver, + margin_classify, + margin_combine, + percentage_resolver, + absolute_resolver, + float_resolver, + relpos_resolver, + height_resolver, + containing_block_resolver, + transform_resolver, + inline_atomic_resolver, + viewport, + child_floats, + ) + }; // The height equation (CSS 2.1 10.6): plain-data in through `height_resolver`, the // seam this trivial layout no longer resolves inline @@ -1376,6 +1625,7 @@ fn layout_box( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, ); // That box's own out-of-flow descendants come back with their chains already @@ -1414,6 +1664,16 @@ fn layout_box( relative_shift_x, relative_shift_y, needs_float_pass: stacked.needs_float_pass && !establishes_bfc, + // CSS 2.1 10.8.1's second route to the bottom-margin-edge fallback: a box whose + // `overflow` computes to anything other than `visible` hands no baseline upward + // however many line boxes it holds (`LayoutResult::last_line_baseline`). + last_line_baseline: if computed::overflow_x(document, node) != "visible" + || computed::overflow_y(document, node) != "visible" + { + None + } else { + stacked.last_line_baseline + }, } } @@ -1517,6 +1777,7 @@ fn layout_absolute_box( height_resolver: HeightResolver, containing_block_resolver: ContainingBlockResolver, transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, viewport: Rect, ) -> (Fragment, Vec) { let cb_width = cb.width; @@ -1628,6 +1889,7 @@ fn layout_absolute_box( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, Some(FloatFrame { ctx: &mut own_float_context, x: 0.0, y: 0.0 }), ); @@ -1962,6 +2224,7 @@ mod tests { height::resolve_baseline, containing_block::resolve_baseline, transform::resolve_baseline, + crate::inline_atomic::resolve_baseline, ) } diff --git a/crates/layout/src/generated/inline_atomic.provenance.json b/crates/layout/src/generated/inline_atomic.provenance.json new file mode 100644 index 00000000..cb6969cf --- /dev/null +++ b/crates/layout/src/generated/inline_atomic.provenance.json @@ -0,0 +1,16 @@ +{ + "spec_url": "https://www.w3.org/TR/CSS21/visuren.html#inline-formatting", + "section_id": "9.4.2", + "spec_text_hash": "357c67e55641eb736801d55ab06531259a935cc7222f3abce20ba5aa2e643806", + "definition_closure_hash": "f884ef8b9719bad3599fcd058e72a130aa9eeba8754caa07c63b3cca216ea391", + "spec_level": "CSS 2.1", + "spec_revision": "2011-06-07", + "model": "qwen3.8-flash-next", + "prompt_hash": "0dd5e51a6adc28de5da73352c699f4af8a89004588feaeb77354120ea9a0dbf1", + "sampling_seed": 0, + "verification": { + "cases_run": 111, + "cases_passed": 111, + "regression_free": true + } +} \ No newline at end of file diff --git a/crates/layout/src/generated/inline_atomic.rs b/crates/layout/src/generated/inline_atomic.rs new file mode 100644 index 00000000..a6dfc204 --- /dev/null +++ b/crates/layout/src/generated/inline_atomic.rs @@ -0,0 +1,458 @@ +// Admitted candidate `model_0` (qwen3.8-flash-next, 111/111); provenance: inline_atomic.provenance.json +use crate::inline_atomic::{AtomicInline, InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox}; +use dom::computed::{LengthPercentage, TextAlign, VerticalAlign}; + +/// One candidate's implementation: plain data in, plain data out. +pub fn resolve(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + let content_width = inputs.content_width; + let line_height = inputs.line_height; + let strut_baseline = line_height / 2.0; + + let mut placements: Vec = Vec::with_capacity(inputs.inlines.len()); + let mut lines: Vec = Vec::new(); + + if inputs.inlines.is_empty() { + return InlineAtomicOutputs { + placements, + lines, + total_height: 0.0, + last_baseline: None, + }; + } + + // Group inlines into lines + let mut current_line_indices: Vec = Vec::new(); + let mut current_line_width: f64 = 0.0; + let mut line_groups: Vec> = Vec::new(); + + for (i, inline) in inputs.inlines.iter().enumerate() { + let w = inline.margin_box_width; + + // If the current line is empty, always add the box (even if wider than container) + if current_line_indices.is_empty() { + current_line_indices.push(i); + current_line_width = w; + } else { + // Check if adding this box would exceed content_width + if current_line_width + w > content_width { + // Start a new line + line_groups.push(std::mem::take(&mut current_line_indices)); + current_line_width = 0.0; + current_line_indices.push(i); + current_line_width = w; + } else { + current_line_indices.push(i); + current_line_width += w; + } + } + } + // Push the last line if it has items + if !current_line_indices.is_empty() { + line_groups.push(current_line_indices); + } + + let mut y_offset = 0.0; + + for group in line_groups { + // For each line, we need to: + // 1. Determine the baseline position relative to the line box top. + // 2. Calculate the height of the line box. + // 3. Place each box horizontally (text-align) and vertically. + + // Step 1 & 2: Determine baseline and height. + // The strut is always present. Its baseline is at strut_baseline from the top of the strut. + // The strut's top is at some offset. Let's define the line box top as 0 for now, + // and find where the baseline sits within it. + + // We need to find the vertical position of the baseline for the line. + // Let B be the baseline position from the top of the line box. + // The strut occupies [B - strut_baseline, B - strut_baseline + line_height]. + // For baseline-aligned boxes: their baseline is at B. + // Box top = B - inline.baseline + // Box bottom = B - inline.baseline + inline.margin_box_height + // For top-aligned boxes: top edge at 0. + // For bottom-aligned boxes: bottom edge at H (line height). + // For middle-aligned boxes: midpoint at B. + // Top = B - margin_box_height / 2 + // Bottom = B + margin_box_height / 2 + // For length-aligned boxes: baseline at B - shift (shift positive raises box, so baseline moves up?) + // Wait, spec says: "raise (positive value) ... above the baseline". + // If baseline is at B, and we raise the box by shift, the box's baseline moves to B - shift. + // So Box top = (B - shift) - inline.baseline + + // We need to find B and H such that: + // - All boxes fit within [0, H] + // - H >= line_height + // - The strut fits: [B - strut_baseline, B - strut_baseline + line_height] within [0, H] + // So B >= strut_baseline and B - strut_baseline + line_height <= H => B <= H - line_height + strut_baseline + + // Strategy: + // 1. Compute preliminary extents assuming baseline alignment for baseline/middle/length boxes. + // Top-aligned boxes have top=0. Bottom-aligned boxes have bottom=H. + // This is circular for Top/Bottom because H depends on them and they depend on H. + + // Let's separate boxes into: + // - Baseline-relative: Baseline, Middle, Length, Other (treated as Baseline) + // - Top-aligned: Top + // - Bottom-aligned: Bottom + + // Let's compute the constraints on B (baseline position from line top) and H (line height). + + let mut min_top_from_baseline: f64 = 0.0; // Maximum distance ABOVE baseline (negative offset from baseline) + let mut max_bottom_from_baseline: f64 = 0.0; // Maximum distance BELOW baseline + + // Actually, let's track: + // For each baseline-relative box, its top relative to baseline is -inline.baseline. + // Its bottom relative to baseline is -inline.baseline + inline.margin_box_height. + + let mut min_rel_top: f64 = -strut_baseline; // Strut top relative to baseline + let mut max_rel_bottom: f64 = line_height - strut_baseline; // Strut bottom relative to baseline + + let mut has_top_aligned = false; + let mut has_bottom_aligned = false; + + // First pass: baseline-relative boxes + for &idx in &group { + let inline = &inputs.inlines[idx]; + match inline.vertical_align { + VerticalAlign::Baseline | VerticalAlign::Other => { + let rel_top = -inline.baseline; + let rel_bottom = -inline.baseline + inline.margin_box_height; + if rel_top < min_rel_top { min_rel_top = rel_top; } + if rel_bottom > max_rel_bottom { max_rel_bottom = rel_bottom; } + } + VerticalAlign::Length(lp) => { + let shift = match lp { + LengthPercentage::Length(px) => px, + _ => 0.0, + }; + // Positive shift raises the box. So the box's baseline is at B - shift. + // Top relative to B: (B - shift) - inline.baseline - B = -shift - inline.baseline + let rel_top = -shift - inline.baseline; + let rel_bottom = -shift - inline.baseline + inline.margin_box_height; + if rel_top < min_rel_top { min_rel_top = rel_top; } + if rel_bottom > max_rel_bottom { max_rel_bottom = rel_bottom; } + } + VerticalAlign::Middle => { + // Midpoint at B. + // Top = B - h/2. Rel top = -h/2. + // Bottom = B + h/2. Rel bottom = h/2. + let rel_top = -inline.margin_box_height / 2.0; + let rel_bottom = inline.margin_box_height / 2.0; + if rel_top < min_rel_top { min_rel_top = rel_top; } + if rel_bottom > max_rel_bottom { max_rel_bottom = rel_bottom; } + } + VerticalAlign::Top => { + has_top_aligned = true; + } + VerticalAlign::Bottom => { + has_bottom_aligned = true; + } + } + } + + // Now determine B and H. + // The line box top is 0. The baseline is at B. + // The strut occupies [B - strut_baseline, B - strut_baseline + line_height]. + // Baseline-relative boxes occupy [B + rel_top, B + rel_bottom]. + + // If there are no Top/Bottom aligned boxes: + // The line box must contain the strut and all baseline-relative boxes. + // Top of line box = min(0, min(B + rel_top for all boxes)) + // But we define line box top as 0. So we shift everything so the topmost point is 0. + // Let Top_extent = min(min_rel_top, -strut_baseline) (already included in min_rel_top) + // Let Bottom_extent = max(max_rel_bottom, line_height - strut_baseline) (already included) + + // The height H = Bottom_extent - Top_extent. + // The baseline B relative to line box top = -Top_extent. + // Because Top_extent is the most negative offset from baseline. + // If Top_extent is -10, then baseline is 10 units below the top of the line box. + + let mut top_extent = min_rel_top; // Most negative offset from baseline + let mut bottom_extent = max_rel_bottom; // Most positive offset from baseline + + // Handle Top-aligned boxes: + // They align their top edge with the line box top (0). + // Their bottom is at inline.margin_box_height. + // This box contributes a bottom extent of H - B (distance from baseline to bottom of box). + // Wait, if top is at 0, and baseline is at B, then top relative to baseline is -B. + // Bottom relative to baseline is H - B. + // So this box extends from -B to H-B relative to baseline. + // This imposes constraints on B and H. + + // Handle Bottom-aligned boxes: + // They align their bottom edge with the line box bottom (H). + // Their top is at H - inline.margin_box_height. + // Top relative to baseline: H - h - B. + // Bottom relative to baseline: H - B. + + // This is circular. Let's solve iteratively or analytically. + + // Let's assume we know B and H. + // For Top-aligned box i: + // Top at 0. Bottom at h_i. + // Constraint: h_i <= H (box fits in line) + // It affects bottom_extent: max(bottom_extent, H - B) + // It affects top_extent: min(top_extent, -B) + + // For Bottom-aligned box j: + // Bottom at H. Top at H - h_j. + // Constraint: H - h_j >= 0 => H >= h_j + // It affects bottom_extent: max(bottom_extent, H - B) + // It affects top_extent: min(top_extent, H - h_j - B) + + // We need to find B and H such that: + // 1. H >= line_height + // 2. H >= max_height_of_top_aligned_boxes (if any) + // 3. H >= max_height_of_bottom_aligned_boxes (if any) + // 4. The line box contains all boxes. + + // Let's try to find B first, assuming no Top/Bottom boxes, then adjust. + + // If no Top/Bottom boxes: + // H = bottom_extent - top_extent + // B = -top_extent + // Check if H >= line_height. If not, H = line_height. + // If H increases, B might need to adjust? + // The strut is fixed relative to baseline. + // If we just set H = max(bottom_extent - top_extent, line_height), + // and B = -top_extent, + // Then the strut is at [B - strut_baseline, B - strut_baseline + line_height]. + // The line box is [0, H]. + // We need B - strut_baseline >= 0 => B >= strut_baseline. + // And B - strut_baseline + line_height <= H. + + // Let's compute B and H for baseline-relative boxes first. + let mut B = -top_extent; + let mut H = bottom_extent - top_extent; + + if H < line_height { + H = line_height; + // If we increase H, we might need to adjust B to keep the strut centered? + // No, the strut's position is defined by the baseline. + // The baseline position B is determined by the content. + // If H > bottom_extent - top_extent, we have extra space. + // Where does it go? + // The spec says "minimize the line box height". + // So H should be exactly the extent of the content. + // But H must be at least line_height. + // If the content extent is less than line_height, the strut dictates the height. + // The strut is [B - strut_baseline, B - strut_baseline + line_height]. + // The line box is [0, H]. + // If H = line_height, and the content is smaller, the content is centered? + // No, the baseline is fixed by the content. + // If the content extent is 10px, and line_height is 20px. + // top_extent = -5, bottom_extent = 5. B = 5. H = 10. + // But H must be >= 20. So H = 20. + // The strut is at [5 - 10, 5 - 10 + 20] = [-5, 15]. + // The line box is [0, 20]. + // The strut overflows the top? No, the line box must contain the strut. + // So we must adjust B so that the strut fits in [0, H]. + // Strut top = B - strut_baseline >= 0 => B >= strut_baseline. + // Strut bottom = B - strut_baseline + line_height <= H => B <= H - line_height + strut_baseline. + + // If we set H = line_height, then B must be strut_baseline. + // Then the content is positioned relative to B = strut_baseline. + // So we recalculate top_extent and bottom_extent relative to this new B? + // No, the content's position relative to its own baseline is fixed. + // The baseline's position in the line box is B. + // If we force B = strut_baseline, then the content might overflow. + + // Correct approach: + // The line box height H is determined by the tallest content. + // If the content (including strut) requires more than line_height, H is that height. + // If the content requires less, H is line_height. + // In the latter case, the baseline is positioned such that the strut is centered? + // "The half-leading above and below the strut's content area is distributed equally". + // This implies the strut is centered in the line box if the line box is exactly line_height. + // So B = line_height / 2 = strut_baseline. + + if bottom_extent - top_extent < line_height { + B = strut_baseline; + H = line_height; + // Recalculate extents relative to this B? + // The content's position relative to B is fixed. + // So the actual top of content is B + min_rel_top. + // The actual bottom is B + max_rel_bottom. + // We need to ensure these are within [0, H]. + // Since the content extent is less than line_height, and B is centered, + // it should fit. + } + } + + // Now handle Top and Bottom aligned boxes. + // We need to adjust B and H to accommodate them. + + // For Top-aligned boxes: + // They want their top at 0. + // If their top relative to current B is T_rel = -B, then the box top is at B + T_rel = 0. + // This is consistent with B being the baseline position. + // The box bottom is at H_box = h_i. + // This box extends from 0 to h_i. + // Relative to baseline B, it extends from -B to h_i - B. + // So it contributes to top_extent: min(top_extent, -B) + // And bottom_extent: max(bottom_extent, h_i - B) + + // For Bottom-aligned boxes: + // They want their bottom at H. + // Box top is at H - h_j. + // Relative to baseline B, top is H - h_j - B. + // Bottom is H - B. + // Contributes to top_extent: min(top_extent, H - h_j - B) + // Contributes to bottom_extent: max(bottom_extent, H - B) + + // We need to find B and H that satisfy all constraints. + // Let's iterate. + + let mut changed = true; + let mut iterations = 0; + while changed && iterations < 100 { + changed = false; + iterations += 1; + + let mut new_top_extent = min_rel_top; + let mut new_bottom_extent = max_rel_bottom; + + // Include strut + if -strut_baseline < new_top_extent { new_top_extent = -strut_baseline; } + if line_height - strut_baseline > new_bottom_extent { new_bottom_extent = line_height - strut_baseline; } + + // Include baseline-relative boxes (already in min/max_rel) + + // Include Top-aligned + for &idx in &group { + let inline = &inputs.inlines[idx]; + if inline.vertical_align == VerticalAlign::Top { + let h = inline.margin_box_height; + // Top at 0. Relative to B: -B. + // Bottom at h. Relative to B: h - B. + if -B < new_top_extent { new_top_extent = -B; } + if h - B > new_bottom_extent { new_bottom_extent = h - B; } + } + } + + // Include Bottom-aligned + for &idx in &group { + let inline = &inputs.inlines[idx]; + if inline.vertical_align == VerticalAlign::Bottom { + let h = inline.margin_box_height; + // Bottom at H. Top at H - h. + // Relative to B: H - h - B, H - B. + if H - h - B < new_top_extent { new_top_extent = H - h - B; } + if H - B > new_bottom_extent { new_bottom_extent = H - B; } + } + } + + let new_B = -new_top_extent; + let new_H = new_bottom_extent - new_top_extent; + + if (new_B - B).abs() > 1e-9 || (new_H - H).abs() > 1e-9 { + B = new_B; + H = new_H; + changed = true; + } + } + + // Ensure H >= line_height + if H < line_height { + H = line_height; + // Re-center strut? + // If we just increase H, the baseline B might need to shift. + // The strut must fit. + // Strut top = B - strut_baseline >= 0 + // Strut bottom = B - strut_baseline + line_height <= H + // If we increase H, we can keep B the same if it still fits. + // If B - strut_baseline < 0, we need to increase B. + // If B - strut_baseline + line_height > H, we need to decrease B. + + if B - strut_baseline < 0.0 { + B = strut_baseline; + } + if B - strut_baseline + line_height > H { + B = H - line_height + strut_baseline; + } + } + + // Now we have B (baseline from line top) and H (line height). + + // Calculate horizontal placement + let total_width: f64 = group.iter().map(|&i| inputs.inlines[i].margin_box_width).sum(); + let remaining = content_width - total_width; + let mut x_start = 0.0; + match inputs.text_align { + TextAlign::Left | TextAlign::Other => { + x_start = 0.0; + } + TextAlign::Right => { + x_start = remaining; + } + TextAlign::Center => { + x_start = remaining / 2.0; + } + } + + let mut current_x = x_start; + + for &idx in &group { + let inline = &inputs.inlines[idx]; + let w = inline.margin_box_width; + let h = inline.margin_box_height; + + let y_pos; + match inline.vertical_align { + VerticalAlign::Baseline | VerticalAlign::Other => { + // Baseline at B. Box baseline at B. + // Box top = B - inline.baseline + y_pos = B - inline.baseline; + } + VerticalAlign::Length(lp) => { + let shift = match lp { + LengthPercentage::Length(px) => px, + _ => 0.0, + }; + // Baseline at B - shift. + // Box top = (B - shift) - inline.baseline + y_pos = B - shift - inline.baseline; + } + VerticalAlign::Middle => { + // Midpoint at B. + // Top = B - h/2 + y_pos = B - h / 2.0; + } + VerticalAlign::Top => { + // Top at 0 + y_pos = 0.0; + } + VerticalAlign::Bottom => { + // Bottom at H + y_pos = H - h; + } + } + + placements.push(InlinePlacement { + x: current_x, + y: y_offset + y_pos, + }); + + current_x += w; + } + + lines.push(LineBox { + top: y_offset, + height: H, + baseline: B, + }); + + y_offset += H; + } + + let total_height = y_offset; + let last_baseline = lines.last().map(|l| l.top + l.baseline); + + InlineAtomicOutputs { + placements, + lines, + total_height, + last_baseline, + } +} \ No newline at end of file diff --git a/crates/layout/src/generated/mod.rs b/crates/layout/src/generated/mod.rs index cb87635e..9d0f4a23 100644 --- a/crates/layout/src/generated/mod.rs +++ b/crates/layout/src/generated/mod.rs @@ -11,6 +11,13 @@ //! (`generation::admit::beats_committed`), which is the same gate every other file here is //! held to -- only the incumbent differs. //! +//! `inline_atomic` starts from its own function's deliberately wrong baseline too -- one +//! atomic inline per line, stacked down the container's left content edge, which is what +//! the engine did for a `display: inline-block` box before `crate::inline_atomic` +//! existed. Its header says `hand-written` and its verification counts are zero for that +//! reason; the first model candidate to score above it replaces it through the same gate +//! every other file here is held to. +//! //! `overflow` starts from its seam's deliberately wrong baseline -- no box ever scrolls, //! what the engine did before the seam existed -- rather than from a model batch or a //! correct-by-construction rule. Its header says `hand-written` and its verification @@ -21,6 +28,7 @@ pub mod absolute; pub mod containing_block; pub mod float; pub mod height; +pub mod inline_atomic; pub mod margin; pub mod overflow; pub mod percentage; diff --git a/crates/layout/src/inline_atomic.rs b/crates/layout/src/inline_atomic.rs new file mode 100644 index 00000000..5187569d --- /dev/null +++ b/crates/layout/src/inline_atomic.rs @@ -0,0 +1,281 @@ +//! CSS 2.1 inline formatting contexts over atomic inlines with no text -- §9.4.2 (line +//! boxes and how inline-level boxes are distributed among them), §10.8 and §10.8.1 +//! (`line-height`, the strut, and `vertical-align`), and the §10.6.3 auto height a stack +//! of line boxes gives its container -- isolated behind one function with plain-data +//! inputs and outputs (`roadmap.md` -> "Phase 1 — Engine bootstrap" step 4). A generated +//! candidate implements [`InlineAtomicResolver`] against the types in this module alone: +//! +//! - [`resolve`-shaped](InlineAtomicResolver): given the container's content width, its +//! strut `line-height` and `text-align`, and, per atomic inline in document order, its +//! margin-box width and height, its own baseline measured from its margin-box top and +//! its `vertical-align`, returns where each atomic inline's margin box goes in the +//! container's content box, the top, height and baseline of each line box, the total +//! height of the stack, and the last line box's baseline for an enclosing +//! inline-block. +//! +//! The container is a block container whose in-flow children are all `display: +//! inline-block` with explicit `width` and `height`, which is the one kind of inline +//! content that needs no font (`specs/inline-atomic.md`). `crates/layout/src/boxtree.rs` +//! decides which containers those are ([`boxtree::Box::inline_level`](crate::boxtree::Box::inline_level)), +//! and `crates/layout/src/fragment.rs` lays each atomic inline out as a block formatting +//! context root at its explicit size before handing the measurements here -- a candidate +//! never lays anything out itself, it only places boxes whose sizes are already known. +//! +//! Two things a candidate is not asked to do, because `fragment.rs` has already done +//! them. A `vertical-align` percentage is resolved against the box's own `line-height` +//! before it arrives, so [`VerticalAlign::Length`] here always carries +//! `LengthPercentage::Length` (the same already-resolved discipline +//! `crate::absolute::AbsoluteInputs` keeps). And an inline-block's own baseline is +//! measured for it: §10.8.1 puts that baseline at its last in-flow line box's, or at its +//! bottom margin edge when it has no line box or its `overflow` computes to something +//! other than `visible`, and that choice is `fragment.rs`'s, since only `fragment.rs` can +//! see inside the box. +//! +//! **The strut, and why no case here depends on a font.** §10.8 starts every line box +//! with a zero-width inline box carrying the container's own font and `line-height` -- the +//! strut. Its ascent and descent place the baseline inside the line height, so in general +//! the strut reads a font. Every container in this function's corpus sets `font-size: 0` +//! with an explicit `line-height` in px +//! (`crates/harness/src/inline_atomic_cases.rs`'s own module doc), which makes the +//! strut's ascent and descent both zero: its content area is empty, the half-leading is +//! split equally above and below it, and so the strut's baseline sits exactly at the +//! middle of [`InlineAtomicInputs::line_height`]. `middle` then aligns on the baseline +//! itself, since the parent's x-height is zero too. That is the whole of what +//! [`line_height`](InlineAtomicInputs::line_height) means to a candidate: a minimum line +//! box height whose baseline is at its midpoint. +//! +//! Scope. In: one line of atomic inlines and the line breaking that starts a new one when +//! the next box does not fit; an atomic inline wider than the container placed alone on +//! its own line; `vertical-align: baseline | top | bottom | middle | | +//! `; `line-height` on the container as the strut; `text-align: left | right +//! | center`; margins, padding and borders on the atomic inlines; an inline-block's own +//! baseline, and so inline-blocks nested inside inline-blocks; and the container's auto +//! height as the sum of its line boxes. +//! +//! Out, and not modelled anywhere in this module: text and every font-dependent value +//! (`sub`, `super`, `text-top`, `text-bottom`, `line-height: normal` over a non-zero font +//! size); `width: auto` inline-blocks, whose shrink-to-fit width belongs to +//! `min-max-content`; a container mixing block-level and inline-level children, which +//! CSS 2.1 §9.2.1.1 wraps in anonymous block boxes; floats and absolutely positioned +//! boxes inside the inline formatting context; `text-align: justify`; bidi and vertical +//! writing modes; `inline-table`, `inline-flex` and replaced elements. +//! +//! [`resolve_baseline`] is this function's deliberately wrong starting candidate: every +//! atomic inline is stacked vertically at the container's left content edge, one per +//! line, which is exactly what the engine did before this module existed -- `boxtree.rs` +//! gave a `display: inline-block` element one block-level box and `fragment.rs` stacked +//! it in normal flow like any other. + +use dom::computed::{LengthPercentage, TextAlign, VerticalAlign}; + +/// One atomic inline, measured. Sizes are margin-box sizes: §10.8 takes the height of an +/// inline-block in a line box to be the height of its margin box, and the horizontal +/// margins, borders and padding between two atomic inlines are respected the same way +/// (§9.4.2), so a candidate never needs the box's separate edges. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AtomicInline { + /// The box's margin-box width: content width plus its own padding, borders and + /// horizontal margins. This is the width it takes up on a line. + pub margin_box_width: f64, + /// The box's margin-box height, which §10.8 uses as its height in the line box. + pub margin_box_height: f64, + /// This box's own baseline, as a distance down from its margin-box top. §10.8.1: for + /// an inline-block it is the baseline of its last in-flow line box, or its bottom + /// margin edge -- equal to + /// [`margin_box_height`](AtomicInline::margin_box_height) -- when it has no in-flow + /// line box or its `overflow` computes to something other than `visible`. + /// `fragment.rs` has already made that choice (this module's own doc comment). + pub baseline: f64, + /// This box's computed `vertical-align`, with any percentage already resolved + /// against its own `line-height` into [`VerticalAlign::Length`] (this module's own + /// doc comment). [`VerticalAlign::Other`] names the font-dependent keywords this + /// function does not model and never reaches a candidate. + pub vertical_align: VerticalAlign, +} + +/// Plain-data inputs to one inline formatting context, narrowed to what §9.4.2 and §10.8 +/// read. +#[derive(Debug, Clone, PartialEq)] +pub struct InlineAtomicInputs { + /// The container's own content width. Every line box is this wide -- no float can + /// narrow one here, floats inside an inline formatting context being out of scope -- + /// so it is both the space a line has to fill and the width `text-align` distributes + /// leftover space within. + pub content_width: f64, + /// The container's computed `line-height` in CSS pixels: the strut's height, and so + /// the minimum height of every line box, with the strut's baseline at its midpoint + /// (this module's own doc comment). + pub line_height: f64, + /// The container's computed `text-align`, which §9.4.2 applies to the leftover space + /// on a line. [`TextAlign::Other`] -- `justify` above all -- is out of scope and + /// never reaches a candidate. + pub text_align: TextAlign, + /// Every in-flow atomic inline the container holds, in document order. + pub inlines: Vec, +} + +/// Where one atomic inline's margin box goes, as an offset from the container's +/// content-box origin. `fragment.rs` adds the box's own top-left margin back out to reach +/// the border box it actually places, the same split +/// `crate::absolute::AbsoluteOutputs` uses between `left`/`top` and `margin_left`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct InlinePlacement { + pub x: f64, + pub y: f64, +} + +/// One line box. §9.4.2 stacks line boxes with no vertical separation and never overlaps +/// them, so `top` is the previous line box's `top + height` for every line but the first, +/// whose `top` is zero. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LineBox { + /// The line box's top edge, as a distance down from the container's content-box top. + pub top: f64, + /// The line box's height: §10.8's distance from the uppermost box top to the + /// lowermost box bottom, never less than the strut's + /// [`line_height`](InlineAtomicInputs::line_height). + pub height: f64, + /// This line box's baseline, as a distance down from its own + /// [`top`](LineBox::top) -- the baseline every `vertical-align: baseline` box on the + /// line, and the strut, align on. + pub baseline: f64, +} + +/// Where every atomic inline and every line box ended up, and what the container's own +/// height and baseline become. +#[derive(Debug, Clone, PartialEq)] +pub struct InlineAtomicOutputs { + /// One placement per entry of [`InlineAtomicInputs::inlines`], in the same order. + pub placements: Vec, + /// The line boxes, top to bottom. Empty when the container holds no atomic inline at + /// all -- §9.4.2 generates no line box for content that is not there. + pub lines: Vec, + /// The height of the whole stack of line boxes, which §10.6.3 makes the container's + /// content height when its own `height` is `auto`. The last line box's `top + + /// height`, and zero when there is no line box. + pub total_height: f64, + /// The last line box's baseline, as a distance down from the container's content-box + /// top -- what §10.8.1 makes the container's own baseline when the container is + /// itself an inline-block. `None` when there is no line box, which sends §10.8.1 to + /// its bottom-margin-edge fallback instead. + pub last_baseline: Option, +} + +/// One candidate's implementation: plain data in, plain data out. Every candidate module +/// the generation loop compiles exports a function of this type named `resolve`. +pub type InlineAtomicResolver = fn(&InlineAtomicInputs) -> InlineAtomicOutputs; + +/// This function's deliberately wrong starting candidate (this module's own doc comment): +/// no line box ever holds more than one atomic inline, every box sits at the container's +/// left content edge, and `vertical-align`, `text-align` and the strut are all ignored -- +/// the vertical stacking the engine gave a `display: inline-block` box before this module +/// existed. +pub fn resolve_baseline(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + let mut placements = Vec::with_capacity(inputs.inlines.len()); + let mut lines = Vec::with_capacity(inputs.inlines.len()); + let mut y = 0.0; + for inline in &inputs.inlines { + placements.push(InlinePlacement { x: 0.0, y }); + lines.push(LineBox { top: y, height: inline.margin_box_height, baseline: inline.baseline }); + y += inline.margin_box_height; + } + let last_baseline = lines.last().map(|line| line.top + line.baseline); + InlineAtomicOutputs { placements, lines, total_height: y, last_baseline } +} + +/// A `vertical-align` whose percentage has already been resolved, as the plain shift in +/// CSS pixels it stands for, or `None` for a keyword that is not a shift. Positive raises +/// the box above the parent's baseline, which is the direction §10.8.1 gives both +/// `` and ``. A helper rather than part of the function's own data, +/// kept here so a candidate crate compiling against this module alone can reach it. +pub fn baseline_shift(vertical_align: VerticalAlign) -> Option { + match vertical_align { + VerticalAlign::Length(LengthPercentage::Length(px)) => Some(px), + VerticalAlign::Baseline => Some(0.0), + VerticalAlign::Length(LengthPercentage::Percentage(_)) + | VerticalAlign::Length(LengthPercentage::Unresolved) + | VerticalAlign::Top + | VerticalAlign::Bottom + | VerticalAlign::Middle + | VerticalAlign::Other => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inline(width: f64, height: f64) -> AtomicInline { + AtomicInline { + margin_box_width: width, + margin_box_height: height, + baseline: height, + vertical_align: VerticalAlign::Baseline, + } + } + + fn inputs(inlines: Vec) -> InlineAtomicInputs { + InlineAtomicInputs { + content_width: 300.0, + line_height: 20.0, + text_align: TextAlign::Left, + inlines, + } + } + + #[test] + fn baseline_gives_every_box_its_own_line() { + let out = resolve_baseline(&inputs(vec![inline(50.0, 10.0), inline(60.0, 30.0)])); + assert_eq!(out.placements, vec![ + InlinePlacement { x: 0.0, y: 0.0 }, + InlinePlacement { x: 0.0, y: 10.0 }, + ]); + assert_eq!(out.lines.len(), 2, "two boxes that would fit side by side, stacked anyway"); + assert_eq!(out.total_height, 40.0); + } + + /// The wrongness that matters most: two boxes narrow enough to share a line never do. + #[test] + fn baseline_never_puts_two_boxes_on_one_line() { + let out = resolve_baseline(&inputs(vec![inline(10.0, 10.0), inline(10.0, 10.0)])); + assert_eq!(out.placements[1].y, 10.0, "below the first, not beside it"); + assert_eq!(out.placements[1].x, 0.0); + } + + /// The strut is ignored outright: a line box shorter than `line-height` stays short. + #[test] + fn baseline_ignores_the_strut() { + let out = resolve_baseline(&inputs(vec![inline(10.0, 4.0)])); + assert_eq!(out.lines[0].height, 4.0, "not the 20px line-height"); + assert_eq!(out.total_height, 4.0); + } + + #[test] + fn baseline_ignores_text_align() { + let mut i = inputs(vec![inline(10.0, 10.0)]); + i.text_align = TextAlign::Right; + assert_eq!(resolve_baseline(&i).placements[0].x, 0.0, "not flush right"); + } + + #[test] + fn no_inlines_is_no_line_box() { + let out = resolve_baseline(&inputs(Vec::new())); + assert!(out.lines.is_empty()); + assert_eq!(out.total_height, 0.0); + assert_eq!(out.last_baseline, None); + } + + #[test] + fn the_last_baseline_is_measured_from_the_content_top() { + let out = resolve_baseline(&inputs(vec![inline(10.0, 10.0), inline(10.0, 30.0)])); + assert_eq!(out.last_baseline, Some(40.0), "the second line's top plus its own baseline"); + } + + #[test] + fn baseline_shift_reads_a_resolved_length_and_nothing_else() { + assert_eq!(baseline_shift(VerticalAlign::Length(LengthPercentage::Length(6.0))), Some(6.0)); + assert_eq!(baseline_shift(VerticalAlign::Baseline), Some(0.0)); + assert_eq!(baseline_shift(VerticalAlign::Middle), None); + assert_eq!(baseline_shift(VerticalAlign::Length(LengthPercentage::Percentage(0.5))), None); + } +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 9d7ef73e..1ac03d57 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -13,6 +13,7 @@ pub mod float; pub mod fragment; mod generated; pub mod height; +pub mod inline_atomic; pub mod margin; pub mod overflow; pub mod percentage; @@ -79,6 +80,7 @@ pub use generated::absolute::resolve as generated_absolute_resolve; pub use generated::containing_block::resolve as generated_containing_block_resolve; pub use generated::float::resolve as generated_float_resolve; pub use generated::height::resolve as generated_height_resolve; +pub use generated::inline_atomic::resolve as generated_inline_atomic_resolve; pub use generated::margin::classify as generated_margin_classify; pub use generated::margin::combine as generated_margin_combine; pub use generated::overflow::resolve as generated_overflow_resolve; diff --git a/crates/provenance/src/css21_inline_atomic.rs b/crates/provenance/src/css21_inline_atomic.rs new file mode 100644 index 00000000..4c25ef47 --- /dev/null +++ b/crates/provenance/src/css21_inline_atomic.rs @@ -0,0 +1,302 @@ +//! Spec text for the inline-atomic generated function (`layout::inline_atomic`, +//! `specs/inline-atomic.md`). One section feeds one function: +//! `layout::inline_atomic::InlineAtomicResolver` resolves CSS 2.1 §9.4.2 and §10.8 +//! through one function (`crates/layout/src/inline_atomic.rs`'s own doc comment). +//! Counterpart of `crate::css21_relative_positioning`, following the same +//! `Fixture: DefinitionIndex` pattern. + +use crate::closure::DefinitionIndex; + +/// `"9.4.2"` names this function's own primary section (CSS 2.1's own table of contents +/// lists it as "Inline formatting context") -- `Fixture::references` reaches every other +/// section the function draws on, §10.8 and §10.8.1 above all. +pub const SECTION_ID: &str = "9.4.2"; +pub const SPEC_URL: &str = "https://www.w3.org/TR/CSS21/visuren.html#inline-formatting"; +pub const SPEC_LEVEL: &str = "CSS 2.1"; + +/// CSS 2.1 §9.4.2's and §10.8's normative text on inline formatting contexts, line boxes +/// and vertical alignment, HTML-wrapped, narrowed to the atomic-inline, text-free case +/// this function's corpus exercises (`crates/harness/src/inline_atomic_cases.rs`). The +/// `` block and the "Changes" section are synthetic, added to exercise the +/// normalizer the same way `crate::css21_relative_positioning::SPEC_TEXT`'s is; +/// everything else is a faithful paraphrase of the sections' actual prose (the exact +/// upstream wording is not available here, mirroring that module's own note about its +/// own paraphrased passages). +pub const SPEC_TEXT: &str = r#" +

9.4.2 Inline formatting contexts

+ +

In an inline formatting context, boxes are laid out horizontally, one after the other, +beginning at the top of a containing block. Horizontal margins, borders and padding are +respected between these boxes. The boxes may be aligned vertically in different ways: +their bottoms or tops may be aligned, or the baselines of text within them may be +aligned. The rectangular area that contains the boxes that form a line is called a line +box.

+ +

The width of a line box is determined by a containing block and the presence of +floats. The height of a line box is determined by the rules given in the section on line +height calculations.

+ +

A line box is always tall enough for all of the boxes it contains. However, it may be +taller than the tallest box it contains (if, for example, boxes are aligned so that +baselines line up). When the height of a box B is less than the height of the line box +containing it, the vertical alignment of B within the line box is determined by the +'vertical-align' property.

+ +

When several inline-level boxes cannot fit horizontally within a single line box, they +are distributed among two or more vertically-stacked line boxes. Thus, a paragraph is a +vertical stack of line boxes. Line boxes are stacked with no vertical separation and they +never overlap.

+ +

In general, the left edge of a line box touches the left edge of its containing block +and the right edge touches the right edge of its containing block. However, floating +boxes may come between the containing block edge and the line box edge. Thus, although +line boxes in the same inline formatting context generally have the same width (that of +the containing block), they may vary in width if available horizontal space is reduced +because of floats.

+ +

An inline-level box that cannot be split -- an inline-block among them -- is placed +alone on a line box if it is wider than the line box: it overflows the line box rather +than being broken across two of them.

+ +

When an inline box exceeds the width of a line box, it is split into several boxes and +these boxes are distributed across several line boxes. An inline-block establishes a +block formatting context for its contents and is itself an atomic inline-level box: it +participates in its parent inline formatting context as a single opaque box that is never +split.

+ +

When the total width of the inline-level boxes on a line is less than the width of the +line box containing them, their horizontal distribution within the line box is determined +by the 'text-align' property. If that property has the value 'left', the boxes are placed +flush with the left edge of the line box and the leftover space is left at the right end; +if 'right', they are placed flush with the right edge and the leftover space is left at +the left end; if 'center', the leftover space is split equally between the two ends, so +that the boxes sit in the middle of the line box.

+ +

10.8 Line height calculations: the 'line-height' and +'vertical-align' properties

+ +

The height of each inline-level box in the line box is calculated. For replaced +elements, inline-block elements and inline-table elements, this is the height of their +margin box; for inline boxes, this is their 'line-height'.

+ +

The inline-level boxes are aligned vertically according to their 'vertical-align' +property. In case they are aligned 'top' or 'bottom', they must be aligned so as to +minimize the line box height. If such boxes are tall enough, there are multiple solutions +and CSS 2.1 does not define the position of the line box's baseline.

+ +

The line box height is the distance between the uppermost box top and the lowermost +box bottom. Empty inline elements generate empty inline boxes, but these boxes still have +margins, padding, borders and a line height, and thus influence these +calculations.

+ +

The 'line-height' property specifies the minimal height of line boxes within the +element. On a block container element whose content is composed of inline-level elements, +'line-height' specifies the minimal height of line boxes within the element. The minimum +height consists of a minimum height above the baseline and a minimum depth below it, +exactly as if each line box starts with a zero-width inline box with the element's font +and line height properties. That zero-width box is called the strut. The half-leading +above and below the strut's content area is distributed equally, so with a font whose +ascent and descent are both zero the strut's baseline sits at the middle of its line +height.

+ +

10.8.1 Leading and half-leading

+ +

This property affects the vertical positioning inside a line box of the boxes +generated by an inline-level element. The following values only have meaning with respect +to a parent inline-level element, or to a parent block container element's strut.

+ +

'baseline': align the baseline of the box with the baseline of the parent box. If the +box does not have a baseline, align the bottom margin edge with the parent's +baseline.

+ +

'middle': align the vertical midpoint of the box with the baseline of the parent box +plus half the x-height of the parent.

+ +

'top': align the top of the aligned subtree with the top of the line box.

+ +

'bottom': align the bottom of the aligned subtree with the bottom of the line +box.

+ +

<percentage>: raise (positive value) or lower (negative value) the box by this +distance (a percentage of the 'line-height' value) above or below the baseline of its +parent. The value '0%' means the same as 'baseline'.

+ +

<length>: raise (positive value) or lower (negative value) the box by this +distance above or below the baseline of its parent. The value '0cm' means the same as +'baseline'.

+ +

The baseline of an 'inline-block' is the baseline of its last line box in the normal +flow, unless it has either no in-flow line boxes or if its 'overflow' property has a +computed value other than 'visible', in which case the baseline is the bottom margin +edge.

+ +

10.6.3 Block-level non-replaced elements in normal flow when +'overflow' computes to 'visible'

+ +

If 'height' is 'auto', the height depends on whether the element has any block-level +children and whether it has padding or borders: the element's height is the distance from +its top content edge to the bottom edge of the bottom-most line box, if the element's +inline-level children form line boxes.

+ + +css/CSS2/linebox/vertical-align-001.html +css/CSS2/linebox/line-height-001.html + + +

Changes

+

Paraphrased rather than quoted verbatim, since the exact upstream text was not +available while writing this generated function; substantively the same rules 9.4.2, +10.8, 10.8.1 and 10.6.3 state, narrowed to the atomic-inline, text-free case this +function's corpus exercises. Text, and with it every font-dependent value -- 'sub', +'super', 'text-top', 'text-bottom' and a 'line-height' of 'normal' over a non-zero font +size -- is out of scope (`specs/inline-atomic.md`'s own "Out" list), as are floats and +absolutely positioned boxes inside the inline formatting context, 'text-align: justify', +bidi and vertical writing modes. No other normative effect.

+"#; + +/// Abbreviated stand-ins for the real definition text a webref `curated` fetch would +/// supply, mirroring `crate::css21_relative_positioning::Fixture`'s shape and depth. +/// `containing-block`/`computed-value`/`specified-value`/`normal-flow` are shared +/// verbatim with that module's own definitions -- the same terms, the same referents. +struct Definition { + id: &'static str, + text: &'static str, + references: &'static [&'static str], +} + +const DEFINITIONS: &[Definition] = &[ + Definition { + id: "containing-block", + text: "The rectangle that generates the containing block for its descendants is \ + called that container's content edge.", + references: &[], + }, + Definition { + id: "computed-value", + text: "The computed value is the value that results from resolving the specified \ + value, generally absolutizing it in preparation for inheritance.", + references: &["specified-value"], + }, + Definition { + id: "specified-value", + text: "The specified value for a property is determined according to the cascade, \ + inheritance and the property's initial value.", + references: &[], + }, + Definition { + id: "normal-flow", + text: "Boxes in the normal flow belong to a formatting context [...] and are laid \ + out one after the other.", + references: &[], + }, + Definition { + id: "line-box", + text: "The rectangular area that contains the boxes that form a line is called a \ + line box.", + references: &["inline-formatting-context", "strut"], + }, + Definition { + id: "inline-formatting-context", + text: "In an inline formatting context, boxes are laid out horizontally, one after \ + the other, beginning at the top of a containing block.", + references: &["normal-flow"], + }, + Definition { + id: "strut", + text: "Each line box starts with a zero-width inline box with the block container \ + element's font and line height properties; that zero-width box is called \ + the strut.", + references: &[], + }, + Definition { + id: "atomic-inline-level-box", + text: "An inline-level box that cannot be split across line boxes -- a replaced \ + element, an inline-block or an inline-table -- participates in its parent \ + inline formatting context as a single opaque box.", + references: &["line-box"], + }, + Definition { + id: "baseline", + text: "The baseline of an 'inline-block' is the baseline of its last line box in \ + the normal flow, unless it has either no in-flow line boxes or its \ + 'overflow' property computes to something other than 'visible', in which \ + case the baseline is the bottom margin edge.", + references: &["line-box"], + }, +]; + +fn find(id: &str) -> Option<&'static Definition> { + DEFINITIONS.iter().find(|d| d.id == id) +} + +/// The `DefinitionIndex` for CSS 2.1 §9.4.2's inline-formatting-context rules. +pub struct Fixture; + +impl DefinitionIndex for Fixture { + fn references(&self, section_id: &str) -> Vec { + if section_id == SECTION_ID { + vec![ + "containing-block".to_string(), + "computed-value".to_string(), + "line-box".to_string(), + "atomic-inline-level-box".to_string(), + "baseline".to_string(), + ] + } else { + Vec::new() + } + } + + fn definition_references(&self, definition_id: &str) -> Vec { + find(definition_id) + .map(|d| d.references.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default() + } + + fn definition_text(&self, definition_id: &str) -> Option { + find(definition_id).map(|d| d.text.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::closure::{closure_for, closure_hash}; + use crate::normalize::normalize_spec_text; + + #[test] + fn closure_reaches_the_transitive_definitions() { + let closure = closure_for(SECTION_ID, &Fixture); + assert_eq!( + closure, + std::collections::BTreeSet::from([ + "containing-block".to_string(), + "computed-value".to_string(), + "specified-value".to_string(), + "line-box".to_string(), + "inline-formatting-context".to_string(), + "normal-flow".to_string(), + "strut".to_string(), + "atomic-inline-level-box".to_string(), + "baseline".to_string(), + ]) + ); + } + + #[test] + fn normalized_spec_text_drops_synthetic_wpt_example_and_changes() { + let normalized = normalize_spec_text(SPEC_TEXT); + assert!(!normalized.contains("vertical-align-001")); + assert!(!normalized.contains("Paraphrased rather than quoted")); + assert!(normalized.contains("line box")); + assert!(normalized.contains("strut")); + } + + #[test] + fn definition_closure_hash_is_deterministic() { + let closure = closure_for(SECTION_ID, &Fixture); + let h1 = closure_hash(&closure, &Fixture); + let h2 = closure_hash(&closure, &Fixture); + assert_eq!(h1, h2); + } +} diff --git a/crates/provenance/src/lib.rs b/crates/provenance/src/lib.rs index 87d6f08b..36fffcfc 100644 --- a/crates/provenance/src/lib.rs +++ b/crates/provenance/src/lib.rs @@ -5,6 +5,7 @@ pub mod closure; pub mod css21_absolute_positioning; pub mod css21_containing_block; +pub mod css21_inline_atomic; pub mod css21_margin_collapse; pub mod css21_percentage_resolution; pub mod css21_relative_positioning; diff --git a/docs/generated-functions.md b/docs/generated-functions.md index a4265c8c..3d79f23e 100644 --- a/docs/generated-functions.md +++ b/docs/generated-functions.md @@ -16,7 +16,7 @@ flowchart LR fragment --> tree["fragment tree"] ``` -## The eleven generated functions +## The twelve generated functions Scores are on each function's own corpus, as recorded in the committed candidate's provenance record under `crates/layout/src/generated/`. The recipe corpus is 227 cases; the other @@ -35,6 +35,7 @@ corpora are per-function. | overflow | scrollbar presence (CSSOM View) | `qwen3.8-27b-fast` | 105/105 | model-first | | containing block | 10.1 which box is a box's containing block | `qwen3.8-flash-next` | 106/106 | model-first | | transform | CSS Transforms 1 §3/§4/§5, the 2D transformation matrix | `qwen3.8-27b-fast` | 110/110 | model-first | +| inline atomic | 9.4.2, 10.8, 10.8.1 line boxes over atomic inlines | `qwen3.8-flash-next` | 111/111 | model-first | On the recipe corpus a function scores only the cases its own rule decides, so width, margin and percentage each fall short of 227 alone; run together in `layout()` they pass 227/227 @@ -47,6 +48,10 @@ is what `position: fixed` needed in order to exist at all. The transform function is the one whose answer is a matrix, and the only one so far not drawn from CSS 2.1. +The inline-atomic function is the one whose answer is a set of positions rather than a +single value, and the first to place more than one box per call: everything before it +decided one box's geometry at a time. + Which of these the engine's real entry points actually call is traced in `roadmap.md` → "Which resolver each generated function runs". @@ -161,6 +166,50 @@ own doc: 3D functions and `perspective`, `transform-box`, the separate `translate`/`rotate`/`scale` properties, a transformed box becoming a containing block or a stacking context, painting, and scrollable overflow. +**Inline atomic** (`crates/layout/src/inline_atomic.rs`), the function that gives the +engine line boxes. Before it, `boxtree.rs` gave every computed `display` other than `none` +one block-level box, so a `display: inline-block` element stacked in normal flow like a +block and `vertical-align`, `line-height` and `text-align` reached nothing at all. Now a +container whose in-flow children are all inline-level establishes an inline formatting +context, `fragment.rs` lays each atomic inline out as a block formatting context root at +its explicit size, and the function decides where on which line each margin box goes, how +tall each line box is, where its baseline sits, and what the container's auto height +becomes. + +Its answer is a set of positions rather than a single value — one placement per input box, +plus the line boxes themselves — which makes it the first function here that can be wrong +about one box and right about its neighbour on the same call, and the first that can panic +on a well-typed input by indexing its own output. The runner catches that the way the +transform runner does. + +Scope is what makes it possible without text. In: line breaking between atomic inlines, +`vertical-align` in all six of its font-free forms, `line-height` as the strut, +`text-align: left | right | center`, margins/padding/borders on the atomic inlines, an +inline-block's own baseline (CSS 2.1 10.8.1's last-in-flow-line-box rule and its +bottom-margin-edge fallback), and the container's auto height as the sum of its line +boxes. Out, and stated in the module's own doc: text and every font-dependent value, +`width: auto` inline-blocks, a container mixing block-level and inline-level children, +floats and absolutely positioned boxes inside the inline formatting context, +`text-align: justify`, bidi, vertical writing modes, `inline-table`, `inline-flex` and +replaced elements. + +The corpus's own design carries the weight. Every container sets `font-size: 0` beside an +explicit `line-height` in px, so the strut's ascent and descent are zero, its baseline sits +at the middle of a known height, and `vertical-align: middle` aligns on that baseline +rather than on half an x-height nobody can measure. `crates/harness/src/cases.rs` already +emits no whitespace between tags, so no text node can appear between two atomic inlines, +and the case file asserts that for every case it produces. 111 cases over fifteen +categories, none dropped for engine disagreement — which is the answer to the open question +this corpus was built around: the three engines agree on a zero-size font's strut. + +Admitted on the first batch at 111/111. Four candidates reached the whole corpus and each +came from a different one of the four gateway models, which makes this the third function +solved completely on its first batch, after floats and transforms. The two seeded-wrong +fixtures are worth more than the usual here: they are wrong about disjoint halves of the +function — one ignores `vertical-align`, the other never breaks a line — and between them +they pass all 111 cases, so the regression set covers the corpus from the first batch and +a candidate must pass everything to be admitted at all. + ## What the generated functions show - **The generated part is small by construction.** The hand-written walk decides which @@ -176,3 +225,8 @@ a stacking context, painting, and scrollable overflow. - **Common failure kinds:** invented types, replies cut off before the code, one plausible misreading of the spec fixed by a single prompt sentence, and harness bugs that only a known-correct candidate exposes. +- **A function's scope is a corpus decision before it is a code decision.** The + inline-atomic function exists at all because one shape of inline content — atomic + inlines at explicit sizes — can be measured without a font, and the corpus is built to + keep every font-dependent value out. Everything the three engines might disagree about + was designed out before the first oracle run rather than dropped after it. diff --git a/docs/model-first-cost.md b/docs/model-first-cost.md index 8d7f5857..208d2a0b 100644 --- a/docs/model-first-cost.md +++ b/docs/model-first-cost.md @@ -882,3 +882,85 @@ first batch's winner called them through the qualified path the prompt shows it once the file sits inside the `layout` crate. The rewrite now covers a qualified path anywhere in a line, and the batch was re-run so the committed file came through the fixed path. + +## Inline atomic: a corpus two wrong fixtures cover exactly + +The inline-atomic function (`specs/inline-atomic.md`, +`crates/layout/src/inline_atomic.rs`) is the first whose answer is a set of positions +rather than one value: a placement for every atomic inline the container holds, the line +boxes they sit on, the stack's total height, and the last line's baseline. + +`inline_atomic_ground_truth_cache` wrote 111 cases to +`corpus/inline-atomic-ground-truth/` (15 enumerated plus 96 seeded-random draws over +`crates/harness/src/inline_atomic_cases.rs`'s fifteen categories) and dropped 0 for engine +disagreement. That is the answer to the question the corpus was designed around. CSS 2.1 +10.8 starts every line box with a strut carrying the container's font, so a line box's +height and baseline normally depend on font metrics the three engines need not share; the +corpus sets `font-size: 0` with an explicit `line-height` in px on every container so the +strut's ascent and descent are zero and its baseline sits at the middle of a known height. +Whether Chromium, Firefox and WebKit would actually agree on that was untested until this +run. They do, on all fifteen categories, `AlignMiddle` and `StrutTallerThanContent` +included — the two that turn on the strut alone. Per-category kept counts: AlignBottom 9, +AlignLength 9, AlignMiddle 3, AlignPercentage 9, AlignTop 8, BaselineDifferentHeights 3, +EdgesOnAtomicInlines 10, LineBreak 12, NestedInlineBlocks 5, OneLineEqualHeights 8, +OverflowHiddenBaseline 7, StrutTallerThanContent 3, TextAlignCenter 8, TextAlignRight 12, +WiderThanContainer 5. + +`inline_atomic_generation_loop` seeds the permanent regression set from the union of every +hand-written fixture's passing cases, the same change `float_generation_loop` and +`transform_generation_loop` already carry. Here the union came out complete — 111 of 111 — +because the two seeded-wrong fixtures are wrong about disjoint halves of the function and +right about everything else, so between them they pass every case: + +| fixture | score | what it gets wrong | categories at 0 | +|---|---|---|---| +| `baseline` | 0/111 | everything: one box per line at the left content edge, the vertical stacking the engine did before the function existed | all fifteen | +| `aligns_every_bottom_edge` | 61/111, 50 regressions | every box's bottom margin edge on the line's baseline, `vertical-align` never read | AlignBottom, AlignLength, AlignMiddle, AlignPercentage, AlignTop, NestedInlineBlocks, OverflowHiddenBaseline | +| `never_breaks_a_line` | 86/111, 25 regressions | one line box however far past the content width it runs | LineBreak, TextAlignCenter, WiderThanContainer | + +Both are rejected, and the shape of the rejection is what makes the corpus trustworthy: +each fixture loses exactly the categories the other passes, so neither can be admitted by +being right about the easy half. A complete regression set also means the gate is as +strict as it can be — a candidate must pass all 111 to be admitted at all, not merely +avoid losing ground. + +The complete seed cost one change to the batch loop. Every other `*_real_model_run` samples +its prompt from the cases outside the regression set, which for every other function is the +same thing as the cases the committed candidate still fails, because the set was seeded +from that candidate's own passes. Here the two differ completely: the set covers the corpus +from the first batch while the committed candidate passes none of it, so the first run of +`inline_atomic_real_model_run` sampled an empty difference, drew nothing and exited in 0.0 +seconds. It now samples over the whole corpus when the difference is empty — the same dead +end `bin/height_real_model_run.rs` reached from the opposite direction, where the committed +candidate passed everything, and the same answer. The ordinary stop condition still applies +here, unlike there: a batch against a committed candidate at 0/111 can genuinely improve on +the one before it. + +One batch, four gateway models at once (`NEV_MODELS` as `specs/inline-atomic.md` gives it; +`NEV_PROVIDER` unset): + +| batch | compiled/drawn | admitted candidate | best score | per-category tally (best) | prompt tokens | output tokens | +|---|---|---|---|---|---|---| +| 1 | 18/82: flash-next 6/16, 27b-fast 4/32, 27b-fast-2 7/32, deepseek-v4.1-flash 1/2 | `model_0` (flash-next; all four models produced a 111/111 candidate) | 111/111 | every category full: AlignBottom 9/9, AlignLength 9/9, AlignMiddle 3/3, AlignPercentage 9/9, AlignTop 8/8, BaselineDifferentHeights 3/3, EdgesOnAtomicInlines 10/10, LineBreak 12/12, NestedInlineBlocks 5/5, OneLineEqualHeights 8/8, OverflowHiddenBaseline 7/7, StrutTallerThanContent 3/3, TextAlignCenter 8/8, TextAlignRight 12/12, WiderThanContainer 5/5 | 675,794 (27b-fast 328,032; 27b-fast-2 328,032; deepseek 19,730; flash-next reported none) | 372,707; slowest single reply 489.4 s (flash-next) | + +Four candidates reached 111/111 and every one of them came from a different model — +`model_0` from `qwen3.8-flash-next`, `model_40` from `qwen3.8-27b-fast`, `model_51` from +`qwen3.8-27b-fast-2`, `model_81` from `deepseek-v4.1-flash`. `model_62` missed by two +cases at 109/111, and the rest of the compiled field collapsed: 93/111 once, then 20, 15, +3, 3, 3, 3, 0, 0. That gap is the shape of the function — line boxes are either built or +they are not, and a candidate that misreads the strut or the baseline loses whole +categories at once rather than drifting by a pixel. + +`qwen3.8-flash-next` reported zero prompt and zero completion tokens on all 16 replies +while returning real source (the winner's own reply was 24,957 bytes), so its share is +missing from the token column. That is the same alias, and the same missing `usage` block, +recorded for the absolute-positioning batches; it behaved differently here from the +transform batch, where it returned no content at all. + +Two candidates panicked partway through the corpus and were caught and dropped rather than +taking the batch with them — `model_42` and `model_9`, both indexing their own output. +That failure mode is more available here than in any earlier function: a candidate returns +one vector of placements and another of line boxes, and getting their lengths out of step +with each other compiles cleanly and panics on the first case that breaks a line. The +isolation `crate::transform_runner::evaluate` introduced is what kept the other sixteen +candidates' scores. diff --git a/handoff.md b/handoff.md index 3d47534d..d42959aa 100644 --- a/handoff.md +++ b/handoff.md @@ -13,71 +13,51 @@ produced by a model batch the same way the landed functions were (width, block h floats, stacking, relative positioning, overflow, containing block, absolute positioning, transforms). -Today `crates/layout/src/boxtree.rs` treats every non-`none` `display` as generating one -block-level box, so an `inline-block` stacks in normal flow. Line boxes are the substrate -that text, table cell baselines and flexbox baseline alignment all sit on, which is why -`tables-fixed-layout` and `display-contents` wait for this track. - ## Current state -Nothing done beyond creating the track. No new modules, no corpus, no batch. The -dependencies `fixed-positioning` and `transforms2d` have both landed, so `boxtree.rs` -and the three `layout*` entry points are at their post-transform shape. +The hand-written side is complete and the corpus, the loop and one model batch have all +run. What exists: + +- `crates/dom/src/computed.rs`: a `Display` enum (`Block`, `FlowRoot`, `InlineBlock`, + `None`, `Other`) with `display()` and `Display::is_inline_level()`, plus + `vertical_align()`, `line_height()` and `text_align()`. `is_display_none` and + `is_display_flow_root` are unchanged and still used at their own call sites. +- `crates/layout/src/boxtree.rs`: `Box::inline_level` and + `BoxTree::establishes_inline_formatting_context`. +- `crates/layout/src/inline_atomic.rs`: `AtomicInline`, `InlineAtomicInputs`, + `InlinePlacement`, `LineBox`, `InlineAtomicOutputs`, `type InlineAtomicResolver`, + `resolve_baseline` (the deliberately wrong vertical stacking) and `baseline_shift`. +- `crates/layout/src/fragment.rs`: `layout_inline_children`, the branch in `layout_box` + that picks it, `LayoutResult::last_line_baseline` and `StackResult::last_line_baseline` + for CSS 2.1 10.8.1, and the new resolver argument on the three `layout*` entry points. +- `crates/harness/src/inline_atomic_cases.rs`: fifteen categories, `enumerated()`, + `random(seed, count)`, and `no_text_between_atomic_inlines`, which every case is + asserted against both in the case file's tests and again in the cache binary. +- `crates/generation/src/inline_atomic_runner.rs`, `Seam::InlineAtomic`, + `prompt::build_inline_atomic`, `admit::record_for_inline_atomic`, the three + `inline_atomic_*` binaries, and three fixtures under + `crates/generation/fixtures/inline-atomic-candidates/`. +- `crates/provenance/src/css21_inline_atomic.rs` and its `pub mod` line. +- `crates/layout/src/generated/inline_atomic.rs` + `.provenance.json`, `pub mod + inline_atomic;` in `generated/mod.rs`, and the three `layout*` entry points defaulting + to it. + +The numbers each run produced are under **Result** below. ## Next steps -1. Build the hand-written side, following `absolute.rs`/`absolute_cases.rs`/ - `absolute_runner.rs` as the closest precedent: - - `crates/dom/src/computed.rs`: a `Display` enum (`Block`, `FlowRoot`, `InlineBlock`, - `None`, `Other`) with a `display()` accessor that keeps `is_display_none` and - `is_display_flow_root` working, plus `vertical_align()`, `line_height()` and - `text_align()`. Additions only. - - `crates/layout/src/boxtree.rs`: each box records whether it is inline-level; a - container whose in-flow children are all inline-level establishes an inline - formatting context. Small hunk — other specs edit this file. - - `crates/layout/src/inline_atomic.rs`: `InlineAtomicInputs`, `InlineAtomicOutputs`, - `type InlineAtomicResolver`, and a deliberately wrong `resolve_baseline` that - stacks the atomic inlines vertically the way the engine does now. - - `crates/layout/src/fragment.rs`: lay each inline-block out as a block formatting - context root at its explicit size, hand the measurements to the resolver, place the - fragments where it says, and feed the resolver's total height to the container's - `HeightInputs::content_height_from_children` (`crates/layout/src/height.rs`). One - more resolver argument on the three `layout*` entry points. - - `crates/harness/src/inline_atomic_cases.rs`: own `Category` enum with - `pub const ALL`, `Case`, `enumerated()`, `random(seed, count)`, built on - `cases::{Elem, Source, px, RESET}`. Never add to `cases.rs` `RECIPES`. - - `crates/generation/src/inline_atomic_runner.rs`, a `Seam::InlineAtomic` variant in - `candidate_crate.rs`, `prompt::build_inline_atomic`, - `admit::record_for_inline_atomic`, the three `bin/` binaries, and one baseline plus - two seeded-wrong fixtures under - `crates/generation/fixtures/inline-atomic-candidates/`. -2. Ground truth: `cargo run --release -p generation --bin - inline_atomic_ground_truth_cache`. Record the case count and the drops for engine - disagreement. More than a fifth of a category dropping is a finding to record, not a - corpus bug to paper over. -3. Generation loop: `inline_atomic_generation_loop`. Both seeded-wrong fixtures must be - rejected; if one is admitted, the loop or the corpus is wrong, not the fixture. -4. Real model run: `inline_atomic_real_model_run` with `NEV_PROVIDER` unset and - `NEV_MODELS=qwen3.8-flash-next:16:16,qwen3.8-27b-fast:32:32,qwen3.8-27b-fast-2:32:16,deepseek-v4.1-flash:2:2:1800`. - Print per-model counts the way `overflow_real_model_run` does so provenance records - the winning model. `NEV_PROVIDER=claude-code` is the fallback only if the gateway does - not answer, and that fallback is recorded as a Decision. Never - `NEV_PROVIDER=anthropic`. -5. On admission, commit the candidate byte for byte as - `crates/layout/src/generated/inline_atomic.rs` with the header line - `generated/width.rs` uses, `inline_atomic.provenance.json` beside it, - `pub mod inline_atomic;` in `generated/mod.rs`, and the three `layout*` entry points - defaulting to it. Record every batch in `docs/model-first-cost.md` → "Batch scores and - token totals" and append the outcome to `roadmap.md` → "Phase 1 — Engine bootstrap" - step 4. -6. Documentation: a new provenance module whose file name contains `inline_atomic` (for - example `crates/provenance/src/css21_inline_atomic.rs`) carrying the spec text the - function is generated against, its one `pub mod` line in - `crates/provenance/src/lib.rs`, and the function's table row and "Per function" entry - in `docs/generated-functions.md`. - -Before every model or oracle run: `set -a; source .env; set +a` in the worktree — the -binaries do not load `.env` themselves. Never print or commit a value from it. +1. Read **Result**. If no candidate was admitted, the next step is more batches: + `cargo run --release -p generation --bin inline_atomic_real_model_run` with the + environment `specs/inline-atomic.md` gives (`set -a; source .env; set +a` first, + `NEV_PROVIDER` unset, `NEV_MODELS` as the spec lists it). The loop stops itself on two + flat batches or after six. +2. A hand-written candidate is allowed only after every batch has failed to admit one, + and is recorded as an escalation with the batch count that preceded it + (`specs/inline-atomic.md`). +3. A floor for this function in `bench/seam-floor.sh` is a hand change made after this + track lands, not part of it: the landing script refuses a diff under `bench/`, and a + score line in `seam_floor_check` with no cache rebuild behind it would fail every + later landing. ## Decisions @@ -94,10 +74,37 @@ binaries do not load `.env` themselves. Never print or commit a value from it. explicit `line-height` in px, so the strut's ascent and descent are zero, the baseline sits at the middle of the strut, and no case depends on a font's metrics. That holds until `text-metrics-oracle` has measured whether the three engines agree on real text. - The case file's module doc records the choice. + The case file's module doc records the choice. The ground-truth run measured that the + three engines do agree on it: 111 cases, 0 dropped. - `crates/harness/src/cases.rs` `document()`/`write_body()` write no whitespace between tags, so no text node can appear between atomic inlines; the new case file asserts that - for every case it produces. + for every case it produces, and `inline_atomic_ground_truth_cache` asserts it again on + the markup it is about to send to the browsers. +- A container establishes an inline formatting context only when it has at least one + child and *every* child is inline-level. A float or an absolutely positioned box beside + an atomic inline disqualifies the container, which keeps it on the stacking path it was + always on — both are out of this function's scope, and falling back is safer than + placing some of the children and dropping the rest. +- `is_bfc_root` now counts an inline-level box: CSS 2.1 9.4.1 names inline-blocks among + the boxes that establish a block formatting context, and without it an atomic inline's + contents' margins could escape onto the line it sits on. No document without an + `inline-block` in it can reach the new arm, so no existing score moves. +- An inline-block's own baseline (10.8.1) is `fragment.rs`'s to decide, not a candidate's: + `LayoutResult::last_line_baseline` carries its last in-flow line box's baseline up, or + `None` when it has none or its `overflow` is not `visible`, and the caller placing it on + a line applies the bottom-margin-edge fallback. `stack_children` propagates its last + in-flow child's, so an inline-block whose own children are block-level still ends in the + right place. +- `vertical-align` is not a longhand in this build's Stylo (0.21.0): it is CSS Inline 3's + shorthand over `alignment-baseline`/`baseline-shift`/`baseline-source`, so + `computed::vertical_align` reads the two longhands that carry the CSS 2.1 keywords and + recombines them. Its unit tests cover all six forms. +- `inline_atomic_real_model_run` samples its prompt over the whole corpus when the + regression set already covers it. Every other `*_real_model_run` samples the cases + outside that set, which elsewhere means the cases the committed candidate fails; here + the seed is complete from the first batch while the committed candidate passes none of + it, so the first run drew nothing and exited immediately. This is the same dead end + `bin/height_real_model_run.rs` reached from the opposite direction. - The provisional rule about lifting adjacent hand-written logic into an existing generated function does not apply: no existing generated function places anything on a line. @@ -105,10 +112,6 @@ binaries do not load `.env` themselves. Never print or commit a value from it. updated here. `docs/dashboard.md` asks for it, but those files belong to `specs/dashboard-history-graph.md`, and the landing script refuses any file outside `touches`. -- A floor for this function in `bench/seam-floor.sh` is a hand change made after this - track lands, not part of it: the landing script refuses a diff under `bench/`, and a - score line in `seam_floor_check` with no cache rebuild behind it would fail every later - landing. - Shared files take small additive hunks only: `fragment.rs`, `lib.rs`, `crates/generation/**`, `computed.rs`, `crates/harness/src/lib.rs`, `generated/mod.rs`, `roadmap.md`, `docs/model-first-cost.md`. @@ -118,25 +121,22 @@ binaries do not load `.env` themselves. Never print or commit a value from it. ## Key files & symbols - `specs/inline-atomic.md` — the authority. -- `crates/layout/src/absolute.rs`, `crates/harness/src/absolute_cases.rs`, - `crates/generation/src/absolute_runner.rs` — the closest precedent for the full set. -- `crates/layout/src/boxtree.rs` — module doc's "exactly one box" claim, the structural - change. -- `crates/dom/src/computed.rs` `is_display_none()`, `is_display_flow_root()` — the - accessors the new `Display` enum must keep working. -- `crates/layout/src/height.rs` `HeightInputs::content_height_from_children` — where the - resolver's total height goes. -- `crates/layout/src/generated/width.rs` — the generated-file header line to copy. -- `crates/generation/src/candidate_crate.rs` `Seam`, `prompt.rs`, `admit.rs`, - `orchestrate.rs` `print_per_model_summary()`, `best_per_model()`, `score_share()`, - `conclude_batch()` — the batch scaffolding. -- `crates/generation/src/bin/overflow_real_model_run.rs` — the per-model reporting shape - to follow. -- `crates/provenance/src/css21_containing_block.rs` — the pattern for the new provenance - module. +- `crates/layout/src/inline_atomic.rs` — the function's types, `resolve_baseline` and the + scope statement. +- `crates/layout/src/fragment.rs` `layout_inline_children()`, `resolved_vertical_align()` + — where the measurements are taken and the fragments placed. +- `crates/layout/src/boxtree.rs` `Box::inline_level`, + `BoxTree::establishes_inline_formatting_context()` — the structural change. +- `crates/dom/src/computed.rs` `display()`, `vertical_align()`, `line_height()`, + `text_align()` — the new accessors. +- `crates/harness/src/inline_atomic_cases.rs` `InlineAtomicCategory`, + `no_text_between_atomic_inlines()` — the corpus and its text-free assertion. +- `crates/generation/src/inline_atomic_runner.rs`, `prompt.rs` `build_inline_atomic()`, + `admit.rs` `record_for_inline_atomic()`, `candidate_crate.rs` `Seam::InlineAtomic` — + the batch scaffolding. +- `crates/provenance/src/css21_inline_atomic.rs` — the spec text the function is + generated against. - `docs/generated-functions.md`, `docs/model-first-cost.md`, `roadmap.md` — the records. -- `docs/infrastructure.md` → "Build lane", "Rig change log" — lanes and model - concurrencies. ## How to verify @@ -150,3 +150,53 @@ binaries do not load `.env` themselves. Never print or commit a value from it. - Every committed file is inside the spec's `touches`. **Result** + +What exists that did not before: a twelfth generated function, `inline_atomic`, and the +line boxes it needs. `boxtree.rs` now records whether a box is inline-level and which +containers establish an inline formatting context; `fragment.rs` lays those containers' +children out on lines through `layout_inline_children` instead of stacking them; `dom` has +`display()`, `vertical_align()`, `line_height()` and `text_align()`; and +`crates/harness/src/inline_atomic_cases.rs` generates a fifteen-category corpus that needs +no font. Before this, `display: inline-block` stacked in normal flow like a block and +`vertical-align`, `line-height` and `text-align` reached nothing at all. + +The runs, in order: + +- `inline_atomic_ground_truth_cache`: 111 cases written, **0 dropped for engine + disagreement**, every category intact. This answers the question the corpus was designed + around — Chromium, Firefox and WebKit agree on a zero-size font's strut, so the + `font-size: 0` plus explicit `line-height` construction holds. +- `inline_atomic_generation_loop`: both seeded-wrong fixtures rejected. + `aligns_every_bottom_edge` 61/111 losing 50, `never_breaks_a_line` 86/111 losing 25, + `baseline` 0/111 losing all 111. The two wrong fixtures pass disjoint halves of the + corpus and union to all 111, so the regression set covers everything and a candidate + must pass the whole corpus to be admitted. +- `inline_atomic_real_model_run`, batch 1 over the four gateway models: 18 of 82 compiled, + and **four candidates reached 111/111, one from each model**. `model_0` from + `qwen3.8-flash-next` is committed as + `crates/layout/src/generated/inline_atomic.rs` with `inline_atomic.provenance.json` + beside it. Two candidates panicked mid-corpus and were caught and dropped rather than + taking the batch down. + +Verify gate: `cargo test --workspace` passes (the whole workspace, `no_seam_word` +included). `fragment_check --random` is 227/227 with every category unchanged, both before +the change and with the admitted candidate running. `bench/seam-floor.sh` rebuilt all four +caches and every floor holds exactly: floats 109/109, stacking-order 105/105, +relative-positioning 104/104, block-height 105/105. + +Nothing needs a decision. Two things are worth knowing: + +- The build lane is configured but was unreachable for this whole session, so every run + fell back to this machine (`bench/on-lane.sh` reports it and falls back by design). The + model gateway itself answered normally. Nothing was worked around; the fallback is the + script's own behaviour. +- `qwen3.8-flash-next` reports zero prompt and zero completion tokens on every reply while + returning real source, so its share is missing from the token totals in + `docs/model-first-cost.md`. Same alias, same missing `usage` block, as the + absolute-positioning batches. + +The spec is satisfied as written, with one deviation worth naming: `bin/ +inline_atomic_real_model_run.rs` samples its prompt over the whole corpus when the +regression set already covers it. The spec assumes the prompt is sampled from the cases +the engine still fails, which for every earlier function is the same set; here it is not, +and sampling the empty difference drew no batch at all on the first attempt. diff --git a/roadmap.md b/roadmap.md index c4b4c7f2..7912b6ab 100644 --- a/roadmap.md +++ b/roadmap.md @@ -120,6 +120,7 @@ others held fixed; nothing on the real path calls them. | overflow | `scrollbars()`, which only `held_out_check` calls | `specs/overflow-scrollbars.md` | | containing block | `layout_box()` at the tree root, once per out-of-flow box | `specs/fixed-positioning.md` | | transform | `box_transform()`, once per box whose `transform` is not `none` | `specs/transforms2d.md` | +| inline atomic | `layout_inline_children()`, once per container whose in-flow children are all inline-level | `specs/inline-atomic.md` | | stacking | nothing: `paint_order()` defaults to it but `paint` draws in tree order | `specs/stacking-order.md` | Follow-up: have `paint` draw in `paint_order()`'s order. Each generated function was checked to be live @@ -173,14 +174,25 @@ is generated. resolution, then new generated functions model-first. Each lands when its category stops improving. Per-function results: [`docs/generated-functions.md`](docs/generated-functions.md). - Latest: the transform function (CSS Transforms 1 §3/§4/§5), the first drawn from a spec + Latest: the inline-atomic function (CSS 2.1 9.4.2/10.8/10.8.1), which gives the engine + line boxes for the one kind of inline content that needs no font — a block container + whose in-flow children are all explicitly sized `display: inline-block` boxes. Until it + existed `boxtree.rs` gave every non-`none` `display` one block-level box and an + `inline-block` simply stacked in normal flow. Its answer is a set of positions rather + than a single value, and it is the first to place more than one box per call. Committed + candidate `model_0` from `qwen3.8-flash-next` at 111/111 on its own corpus, admitted on + the first batch, with one candidate from each of the four gateway models reaching + 111/111. Corpus of 111 cases over fifteen categories, none dropped for engine + disagreement (`specs/inline-atomic.md`). + + Before it: the transform function (CSS Transforms 1 §3/§4/§5), the first drawn from a spec other than CSS 2.1 and the first whose answer is a matrix. Until it existed `transform` was ignored outright and every box was reported where normal flow put it. Committed candidate `model_22` from `qwen3.8-27b-fast` at 110/110 on its own corpus, admitted on the first batch, with twelve of the twenty candidates that compiled tied at 110/110 (`specs/transforms2d.md`). - Before it: the containing-block function (CSS 2.1 10.1), which is what `position: fixed` + Before that: the containing-block function (CSS 2.1 10.1), which is what `position: fixed` needed — a fixed box's containing block is the viewport, not the nearest positioned ancestor, and until this function existed `fragment.rs` decided the question itself and `fixed` was flattened to `static`. Committed candidate `model_0` from