diff --git a/.agent/plans/css_inline_baseline_alignment_plan.md b/.agent/plans/css_inline_baseline_alignment_plan.md new file mode 100644 index 000000000..33c5fec89 --- /dev/null +++ b/.agent/plans/css_inline_baseline_alignment_plan.md @@ -0,0 +1,233 @@ +# CSS Inline Baseline Alignment Plan + +## Goal + +Add spec-aligned support for CSS inline baseline alignment now that `RichText::CustomBlock` can carry real baselines. + +Initial scope: + +- `vertical-align` for inline-level boxes and table cells where already practical. +- `alignment-baseline` as an inline SVG/CSS alignment property that can share the same inline baseline machinery. +- Shared internal representation for baseline alignment so text spans, inline-blocks, images/custom blocks, and future SVG text can use the same layout path. + +This must be implemented as generic inline formatting behavior, not as element-specific fixes. + +## Specification References + +Primary references: + +- CSS 2.2 `vertical-align`: https://www.w3.org/TR/CSS22/visudet.html#propdef-vertical-align +- CSS 2.2 inline formatting model: https://www.w3.org/TR/CSS22/visuren.html#inline-formatting +- CSS Inline Layout Level 3 alignment terms: https://www.w3.org/TR/css-inline-3/ +- SVG 2 `alignment-baseline`: https://www.w3.org/TR/SVG2/text.html#AlignmentBaselineProperty + +Important spec notes: + +- `vertical-align` applies to inline-level boxes and table cells, and is not inherited. +- Percentage values for `vertical-align` refer to the element's own `line-height`. +- Keyword values adjust a box relative to the parent line box baseline, text metrics, or line box. +- `alignment-baseline` is inherited in SVG contexts and should eventually apply to SVG text/layout. For eepp's HTML compatibility layer, it can initially map to the same baseline-alignment value type used by inline formatting. + +## Current State + +Relevant implementation points: + +- `RichText::SpanBlock` stores text, margin, padding, and optional line-height. +- `RichText::CustomBlock` stores size, float/clear, and baseline. +- `RichText::updateLayout()` computes `line.maxAscent`, then assigns `span.position.y` from the baseline. +- Text spans currently use `line.maxAscent - textBlock->getCharacterSize()`, which is only an approximation of baseline alignment. +- Custom blocks use `line.maxAscent - custom.baseline`, which is the right extension point for inline-blocks and replaced content. +- `UIRichText::rebuildRichText()` already computes custom-block baselines from internal RichText lines. +- There is no implemented `vertical-align` property. Existing `row-valign` / `row-vertical-align` is unrelated table row UI behavior and should not be reused as CSS `vertical-align`. + +## Design + +### 1. Add A Shared Alignment Type + +Add a CSS-facing value type, likely in `csslayouttypes.hpp`, such as: + +```cpp +enum class CSSBaselineAlignment { + Baseline, + Sub, + Super, + TextTop, + TextBottom, + Middle, + Top, + Bottom, + Length, + Percentage, + Auto +}; +``` + +Also store the numeric offset for `` and `` values. A small struct is preferable to overloading the enum: + +```cpp +struct CSSBaselineAlignValue { + CSSBaselineAlignment type{ CSSBaselineAlignment::Baseline }; + std::string specified; + Float value{ 0.f }; + bool percentage{ false }; +}; +``` + +The actual names can be adjusted to match existing eepp style. + +### 2. Register CSS Properties + +In `StyleSheetSpecification::registerDefaultProperties()`: + +- Register `vertical-align` with initial value `baseline`, not inherited. +- Register `alignment-baseline` with initial value `baseline` or `auto` according to the chosen supported subset. Mark it inherited only if implementation targets the SVG/SVG-text behavior from the spec. + +Do not alias `vertical-align` to existing `row-valign`; that would conflate separate CSS concepts. + +### 3. Store The Computed Values On HTML Widgets + +Add baseline alignment storage at the lowest common inline-participation layer: + +- `UIHTMLWidget` should store the parsed value for boxes that become `CustomBlock`s. +- `UITextSpan` / `UIRichText` should expose it for text spans and nested inline content. +- Replaced elements such as `UIHTMLImage` inherit this through `UIHTMLWidget` once they are represented as custom blocks. + +Implementation options: + +- Add fields and accessors to `UIHTMLWidget`, and let `UIRichText`/`UITextSpan` use the same inherited logic. +- Or add the property to `UIWidget` only if non-HTML UI widgets need baseline alignment in RichText. Prefer `UIHTMLWidget` for now. + +Property application: + +- `vertical-align` accepts keywords, lengths, and percentages. +- `alignment-baseline` accepts a keyword subset initially: `baseline`, `middle`, `text-before-edge`/`text-top`, `text-after-edge`/`text-bottom`, `central`, `before-edge`, `after-edge`, `hanging`, `mathematical`, and `auto` as feasible. +- Unsupported keywords should parse to a known fallback only if the spec allows it; otherwise leave the property unapplied. + +### 4. Extend RichText Blocks With Alignment Metadata + +Extend `RichText::SpanBlock` and `RichText::CustomBlock` with baseline alignment metadata: + +```cpp +CSSBaselineAlignValue baselineAlign; +``` + +Update: + +- `RichText::addSpan(...)` +- `RichText::addCustomSize(...)` +- all call sites in `UIRichText::rebuildRichText()` +- line-break custom block construction + +Default value must preserve current behavior: baseline alignment. + +### 5. Compute Text Baselines From Font Metrics + +Replace the current text offset approximation: + +```cpp +line.maxAscent - textBlock->getCharacterSize() +``` + +with real font metrics: + +- ascent from `Font::getAscent(characterSize)`, +- descent/line spacing if available, +- used line height from `SpanBlock::lineHeight` or normal font line spacing. + +The text baseline for a text span should be its ascent from the top of its own inline box. This aligns with the existing custom-block model, where baseline is an offset from the top of the box. + +### 6. Apply Baseline Alignment During Line Layout + +Add a helper in `RichText`, conceptually: + +```cpp +Float resolveBaselineOffset( + const RenderParagraph& line, + const RenderSpan& span, + Float naturalBaseline, + const CSSBaselineAlignValue& align, + const FontStyleConfig* parentFontStyle ); +``` + +Supported `vertical-align` behavior: + +- `baseline`: no extra shift. +- ``: raise the box by positive length and lower by negative length. +- ``: same as length, resolved against the element's own line-height. +- `sub` / `super`: use font-derived or conservative spec-compatible offsets. Prefer a font metric if available; otherwise use a documented fraction of font size. +- `text-top`: align the top of the box with the parent content area's top. +- `text-bottom`: align the bottom of the box with the parent content area's bottom. +- `middle`: align the midpoint of the box with the parent baseline plus half x-height. If x-height is unavailable, use a documented fallback based on font size. +- `top`: align the top of the box with the top of the line box. +- `bottom`: align the bottom of the box with the bottom of the line box. + +Implementation may need two passes: + +1. Compute natural baselines, ascent/descent extents, and provisional line box metrics. +2. Apply vertical-align shifts and expand line height if shifted boxes extend outside current line bounds. + +This must be done in both RichText paths: + +- fast path without floats, +- float-aware path. + +Float placement must remain edge-aligned and should ignore inline baseline alignment, as documented in `html-layout-architecture.md`. + +### 7. Map `alignment-baseline` + +For the first implementation: + +- If `vertical-align` is specified, it controls CSS inline alignment for HTML inline-level boxes. +- If `alignment-baseline` is specified and `vertical-align` is not specified, map supported keywords to the shared baseline alignment value for inline/SVG contexts. +- Keep unsupported SVG-specific values documented as TODOs. + +Do not pretend full SVG text layout is complete if only inline block alignment is wired. + +## Implementation Steps + +1. Add property IDs/specification entries for `vertical-align` and `alignment-baseline`. +2. Add parser/helper functions for baseline alignment values. +3. Store computed alignment value on `UIHTMLWidget` / `UIRichText` / `UITextSpan`. +4. Extend `RichText::SpanBlock` and `RichText::CustomBlock` to carry alignment metadata. +5. Update `UIRichText::rebuildRichText()` to pass each node's computed alignment into `addSpan()` / `addCustomSize()`. +6. Replace text baseline approximation with real font ascent-based baseline. +7. Implement alignment resolution in `RichText::updateLayout()` fast path. +8. Mirror the same alignment resolution in the float-aware path. +9. Add tests. +10. Update `.agent/rules/html-layout-architecture.md` if implementation details differ from this plan. + +## Tests + +Add narrow unit tests in `richtext_tests.cpp`: + +- Text span with `vertical-align: baseline` preserves current baseline placement. +- Inline custom block with `vertical-align: middle` is vertically centered relative to surrounding text. +- Inline custom block with `vertical-align: text-top` and `text-bottom`. +- Inline custom block with positive and negative length values. +- Inline custom block with percentage values based on its own line-height. +- Mixed text sizes where font ascent, not character size, controls baseline. + +Add HTML/UI tests in `uihtml_tests.cpp`: + +- `img { vertical-align: middle; }` in a text line. +- `details.caches { display: inline-block; vertical-align: baseline; }` remains unchanged from the Lobsters regression. +- `details.caches { vertical-align: middle; }` moves only by baseline alignment, not by changing element height. +- `alignment-baseline: middle` maps to the same behavior where supported. + +Regression guard: + +- Existing `UIHTMLDetails.*`, `UILayout.listStyle*`, and `UIRichText.MinMaxWidthChildren` must remain green. + +## Risks + +- `vertical-align: top` and `bottom` require line-box-level alignment and may need a clean second pass to avoid circular line-height changes. +- Text metrics may differ by font backend. Tests should use tolerances and the existing unit-test font. +- SVG-specific `alignment-baseline` keywords are more nuanced than HTML inline `vertical-align`; document any unsupported values clearly. +- Existing tests may encode the old character-size baseline approximation. Prefer updating them to assert spec behavior rather than preserving the approximation. + +## Non-Goals For First Pass + +- Full SVG text layout. +- CSS Box Alignment properties unrelated to inline baselines. +- Table-cell `vertical-align` beyond mapping to existing table-cell placement if the table layouter does not already expose the needed hooks. +- Browser-perfect x-height if the active font API cannot expose it yet. diff --git a/.agent/plans/first_class_inline_boxes_plan.md b/.agent/plans/first_class_inline_boxes_plan.md new file mode 100644 index 000000000..b5b1d0817 --- /dev/null +++ b/.agent/plans/first_class_inline_boxes_plan.md @@ -0,0 +1,264 @@ +# First-Class Inline Boxes Plan + +## Goal + +Replace the current RichText flattening approximation with a real inline formatting tree where inline boxes are modeled as first-class layout objects. + +This is needed to correctly support nested inline CSS behavior such as: + +- `vertical-align` on an inline parent containing child text or anchors. +- Inline margins, padding, borders, and backgrounds across nested content. +- Accurate line box ascent/descent aggregation. +- Correct inline hit boxes and child widget positioning after line wrapping. + +The implementation must preserve the current good behavior from the baseline alignment work and avoid element-specific fixes. + +## Specification References + +Primary references: + +- CSS 2.2 inline formatting model: https://www.w3.org/TR/CSS22/visuren.html#inline-formatting +- CSS 2.2 line height and vertical-align: https://www.w3.org/TR/CSS22/visudet.html#line-height +- CSS 2.2 inline non-replaced box dimensions: https://www.w3.org/TR/CSS22/visudet.html#inline-non-replaced +- CSS Display Level 3: https://www.w3.org/TR/css-display-3/ +- CSS Inline Layout Level 3: https://www.w3.org/TR/css-inline-3/ + +Important spec constraints: + +- `vertical-align` applies to inline-level and table-cell boxes, but is not inherited. +- Inline boxes can contain text runs, other inline boxes, and atomic inline-level boxes. +- Inline-blocks are atomic in the parent inline formatting context and expose a baseline according to CSS rules. +- Line boxes are built from the inline boxes that participate in each line, including parent inline box metrics, not only leaf text runs. + +## Current State + +The current pipeline is: + +1. `UIRichText::rebuildRichText()` recursively walks children. +2. Text nodes and inline spans are flattened into `RichText::SpanBlock`. +3. Inline-blocks and replaced content become `RichText::CustomBlock`. +4. `RichText::updateLayout()` lays out a flat list of spans/custom blocks. +5. `BlockLayouter::positionRichTextChildren()` maps render spans back to widgets. + +This works for many cases, but loses the identity of parent inline boxes. The recent `getEffectiveInlineBaselineAlign()` workaround preserves the nearest explicit inline alignment when flattening nested inline text, but it is still an approximation. + +Known limitations caused by flattening: + +- Parent inline `vertical-align` can be lost when visible text is inside a child inline element. +- Inline parent padding/borders/backgrounds cannot be split correctly across line wraps. +- Nested inline hit boxes and visual boxes are reconstructed after the fact instead of being laid out directly. +- Future support for inline border painting, decoration propagation, and richer line-height behavior will be fragile. + +## Target Model + +Introduce an inline formatting model with explicit inline layout items. + +Conceptual node types: + +```cpp +struct InlineTextRun { + String text; + FontStyleConfig style; + UITextNode* sourceNode; +}; + +struct InlineBox { + UIWidget* sourceWidget; + CSSBaselineAlignValue baselineAlign; + Rectf margin; + Rectf padding; + BorderMetrics border; + std::vector children; +}; + +struct AtomicInlineBox { + UIWidget* sourceWidget; + Sizef marginBoxSize; + Float baseline; + CSSBaselineAlignValue baselineAlign; + CSSFloat floatType; + CSSClear clearType; +}; +``` + +Exact names and storage should follow eepp style, but the key requirement is that inline parent boxes survive until line layout. + +## Architecture + +### 1. Build An Inline Tree + +Replace or augment `UIRichText::rebuildRichText()` with a builder that emits an inline tree: + +- Text nodes become text runs. +- True inline `UITextSpan` / `UIHTMLWidget` nodes become `InlineBox` nodes. +- Inline-blocks, images, controls, list items, and other atomic inline-level content become `AtomicInlineBox`. +- Out-of-flow descendants remain skipped and positioned later. +- Floats remain represented as atomic boxes with float metadata. + +Whitespace collapsing should continue to happen in the builder, but it must operate across nested inline boundaries. + +### 2. Shape And Split Text Runs + +Line wrapping must be able to split text runs while preserving ancestor inline boxes. + +Required behavior: + +- A text run can produce multiple line fragments. +- Each line fragment carries the active ancestor inline box chain. +- Ancestor inline boxes generate per-line fragments when their content wraps. +- Leading/trailing inline margins/padding/borders apply according to whether the fragment is the first or last fragment of that inline box. + +Initial implementation can support margins/padding first and leave border/background painting as a follow-up, as long as the fragment model is ready for it. + +### 3. Compute Inline Metrics + +Each inline item should expose: + +- advance width, +- content box height, +- used line height, +- natural baseline, +- ascent/descent extents relative to its own baseline, +- `vertical-align` shift. + +Text runs use font metrics: + +- ascent, +- descent, +- line spacing, +- x-height fallback for `middle`. + +Atomic inline boxes use: + +- provided widget size plus margin, +- internal baseline for inline-blocks when available, +- bottom edge fallback according to CSS. + +Inline parent boxes use the union of child fragments and their own padding/border/margin contribution. + +### 4. Build Line Boxes From Fragments + +The line builder should produce `InlineLine` objects containing fragment boxes, not just leaf render spans. + +Each line should know: + +- line y, +- line height, +- baseline, +- max ascent/descent, +- fragment list, +- width. + +`vertical-align: top` and `bottom` need line-box-level resolution. The algorithm may require: + +1. Build provisional line with baseline-aligned items. +2. Resolve line height. +3. Apply top/bottom aligned items. +4. Recompute final extents if needed. + +This should replace duplicated logic in both RichText paths. The float-aware path can still provide available width and float placement, but inline vertical alignment should be shared. + +### 5. Preserve Widget Mapping + +After layout, map generated inline fragments back to source nodes: + +- Text nodes receive one or more hit boxes. +- Inline widgets receive one or more fragment boxes. +- Inline-block/replaced widgets receive one atomic fragment. +- Parent inline boxes spanning multiple lines receive multiple fragment boxes. + +`BlockLayouter::positionRichTextChildren()` should consume this structured result instead of inferring child positions from flattened spans. + +### 6. Drawing And Hit Testing + +Drawing should eventually use the same fragment list: + +- Text runs draw at fragment positions. +- Inline parent backgrounds/borders draw per fragment. +- Decorations can be applied consistently over fragmented inline boxes. +- Hit testing uses fragment boxes directly. + +Initial migration can keep existing drawing for text while using fragment boxes for widget placement, but the final target should remove duplicated reconstruction logic. + +## Migration Strategy + +### Phase 1: Non-Behavioral Data Model + +- Add internal inline item/fragment structs behind `RichText`. +- Keep existing flat `SpanBlock` / `CustomBlock` APIs working. +- Add debug-only or test-only accessors for generated fragments. +- Do not change rendering behavior yet. + +### Phase 2: Build Inline Tree From HTML + +- Add a new builder path in `UIRichText::rebuildRichText()`. +- Emit nested inline boxes for true inline widgets. +- Keep a compatibility path if needed while tests are ported. +- Preserve whitespace collapsing behavior exactly. + +### Phase 3: Shared Line Layout + +- Implement line construction from inline items. +- Reuse existing wrapping decisions where possible. +- Keep float handling outside the inline algorithm, but feed available line widths into it. +- Ensure fast path and float-aware path share vertical alignment code. + +### Phase 4: Widget Position Mapping + +- Update `BlockLayouter::positionRichTextChildren()` to use inline fragments. +- Support multi-fragment inline widgets. +- Preserve existing positions for atomic inline-blocks, images, list markers, details/summary, and form controls. + +### Phase 5: Remove Flattening Workarounds + +- Remove `getEffectiveInlineBaselineAlign()` once parent inline boxes directly participate in line layout. +- Remove any code that copies parent inline alignment into descendant flattened spans. +- Keep `vertical-align` non-inherited. + +### Phase 6: Painting Improvements + +- Use inline fragments for inline backgrounds, borders, and decoration painting. +- Add support for split inline backgrounds/borders across wrapped lines. +- Review selection rendering and hit boxes against the new fragments. + +## Tests + +Add narrow unit tests for the inline layout engine: + +- Nested inline parent with `vertical-align: bottom` and child anchor text aligns the parent box bottom to the line bottom. +- Child inline element does not inherit `vertical-align` in computed style. +- Nested inline `vertical-align: middle` does not affect sibling baselines incorrectly. +- Inline parent with text before and after child creates one coherent inline box. +- Inline parent split across two wrapped lines produces two fragments. +- Inline-block baseline still follows CSS bottom-edge fallback when it has no in-flow line boxes. +- Inline-block with in-flow internal line boxes exposes its last line baseline. + +Add UIHTML fixture tests: + +- `reddit_header.html`: `.hover.pagename.redditname` bottom-aligns in `#header-bottom-left`. +- `inline_block.html`: footer/share-button heights remain close to browser behavior. +- Existing `reddit_header_icons.html` remains unchanged. +- `UIHTMLDetails.*`, `UIRichText.MinMaxWidthChildren`, and `UILayout.listStyle*` remain green. + +Add RichText tests: + +- Multi-line nested inline box fragments preserve source widget hit boxes. +- `vertical-align: top` and `bottom` resolve after final line height is known. +- Length and percentage vertical-align values work for nested inline boxes. + +## Risks + +- Whitespace collapsing can regress at inline boundaries. +- Text selection and hit testing may shift if fragment ownership changes. +- Float-aware layout can diverge from the fast path if shared inline code is not factored carefully. +- Multi-line inline parent fragments can expose existing assumptions that each widget has one rectangle. +- Golden image diffs are expected; prioritize numeric layout invariants and real browser comparisons before updating goldens. + +## Completion Criteria + +- Nested inline CSS alignment works without copying inherited values to descendants. +- `vertical-align` remains non-inherited in computed style. +- Parent inline boxes have fragment boxes and can affect line metrics directly. +- Existing baseline alignment workaround is removed. +- Current HTML regression tests pass, except intentionally updated goldens. +- New tests cover nested inline boxes, fragmented inline boxes, and realistic reddit header behavior. diff --git a/.ecode/project_build.json b/.ecode/project_build.json index 9e6098a1d..777457d7f 100644 --- a/.ecode/project_build.json +++ b/.ecode/project_build.json @@ -220,7 +220,7 @@ "working_dir": "${project_root}/bin" }, { - "args": "", + "args": "-filter=\"UIRichText.InvalidWidthLengthComputation3\"", "command": "${project_root}/bin/unit_tests/eepp-unit_tests-debug", "name": "eepp-unit_tests-debug", "working_dir": "${project_root}/bin/unit_tests/" @@ -377,7 +377,7 @@ "working_dir": "${project_root}/bin" }, { - "args": "-c system --hn-dark", + "args": "-d1 file:///home/downloads/files/svn/eepp/bin/unit_tests/assets/html/blog_main_incorrect_widths.html", "command": "${project_root}/bin/eepp-ui-html-debug", "name": "eepp-ui-html-debug", "working_dir": "${project_root}/bin" diff --git a/bin/unit_tests/assets/fontrendering/eepp-rich-text.webp b/bin/unit_tests/assets/fontrendering/eepp-rich-text.webp index 83806675e..a25d250d9 100644 Binary files a/bin/unit_tests/assets/fontrendering/eepp-rich-text.webp and b/bin/unit_tests/assets/fontrendering/eepp-rich-text.webp differ diff --git a/bin/unit_tests/assets/fontrendering/eepp-ui-richtext.webp b/bin/unit_tests/assets/fontrendering/eepp-ui-richtext.webp index 83b1eea9e..18f1626dc 100644 Binary files a/bin/unit_tests/assets/fontrendering/eepp-ui-richtext.webp and b/bin/unit_tests/assets/fontrendering/eepp-ui-richtext.webp differ diff --git a/bin/unit_tests/assets/html/eepp-ui-anchor-padding.webp b/bin/unit_tests/assets/html/eepp-ui-anchor-padding.webp index e127945eb..da2c56f9c 100644 Binary files a/bin/unit_tests/assets/html/eepp-ui-anchor-padding.webp and b/bin/unit_tests/assets/html/eepp-ui-anchor-padding.webp differ diff --git a/bin/unit_tests/assets/html/eepp-ui-background-atlas-pd2.webp b/bin/unit_tests/assets/html/eepp-ui-background-atlas-pd2.webp index 70052132e..30292ea92 100644 Binary files a/bin/unit_tests/assets/html/eepp-ui-background-atlas-pd2.webp and b/bin/unit_tests/assets/html/eepp-ui-background-atlas-pd2.webp differ diff --git a/bin/unit_tests/assets/html/eepp-ui-border-rendering-2.webp b/bin/unit_tests/assets/html/eepp-ui-border-rendering-2.webp index 9439bc1bb..8f3ae1caf 100644 Binary files a/bin/unit_tests/assets/html/eepp-ui-border-rendering-2.webp and b/bin/unit_tests/assets/html/eepp-ui-border-rendering-2.webp differ diff --git a/bin/unit_tests/assets/html/eepp-ui-border-rendering.webp b/bin/unit_tests/assets/html/eepp-ui-border-rendering.webp index 19588b25a..1772856a9 100644 Binary files a/bin/unit_tests/assets/html/eepp-ui-border-rendering.webp and b/bin/unit_tests/assets/html/eepp-ui-border-rendering.webp differ diff --git a/bin/unit_tests/assets/html/eepp-ui-inline-block-image-spans.webp b/bin/unit_tests/assets/html/eepp-ui-inline-block-image-spans.webp index c44d05b20..56cd7af14 100644 Binary files a/bin/unit_tests/assets/html/eepp-ui-inline-block-image-spans.webp and b/bin/unit_tests/assets/html/eepp-ui-inline-block-image-spans.webp differ diff --git a/bin/unit_tests/assets/html/eepp-ui-span-padding.webp b/bin/unit_tests/assets/html/eepp-ui-span-padding.webp index 23e1dd1fc..07757ace2 100644 Binary files a/bin/unit_tests/assets/html/eepp-ui-span-padding.webp and b/bin/unit_tests/assets/html/eepp-ui-span-padding.webp differ diff --git a/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-2.webp b/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-2.webp index 8c4ac0df4..e5e383f10 100644 Binary files a/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-2.webp and b/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-2.webp differ diff --git a/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-3.webp b/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-3.webp index 131d62aa2..178d3cfca 100644 Binary files a/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-3.webp and b/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout-3.webp differ diff --git a/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout.webp b/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout.webp index 41e5aa69c..2babc80d4 100644 Binary files a/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout.webp and b/bin/unit_tests/assets/html/eepp-uihtmltable-complex-layout.webp differ diff --git a/include/eepp/graphics/richtext.hpp b/include/eepp/graphics/richtext.hpp index 159ab398d..a6ee80760 100644 --- a/include/eepp/graphics/richtext.hpp +++ b/include/eepp/graphics/richtext.hpp @@ -35,7 +35,8 @@ class EE_API RichText : public Drawable { void addSpan( const String& text, const FontStyleConfig& style ); void addSpan( const String& text, const FontStyleConfig& style, const Rectf& margin, - const Rectf& padding, Float lineHeight = 0 ); + const Rectf& padding, Float lineHeight = 0, + const UI::CSSBaselineAlignValue& baselineAlign = {} ); /** * @brief Adds a text span with individual style parameters. @@ -87,6 +88,7 @@ class EE_API RichText : public Drawable { UI::CSSClear clearType{ UI::CSSClear::None }; Float baseline{ 0 }; bool isLineBreak{ false }; + UI::CSSBaselineAlignValue baselineAlign; }; struct SpanBlock { @@ -94,6 +96,7 @@ class EE_API RichText : public Drawable { Rectf margin; Rectf padding; Float lineHeight{ 0 }; + UI::CSSBaselineAlignValue baselineAlign; }; using Block = std::variant, CustomBlock>; @@ -109,7 +112,8 @@ class EE_API RichText : public Drawable { * @param size The physical dimensions of the spacer. */ void addCustomSize( const Sizef& size, UI::CSSFloat floatType = UI::CSSFloat::None, - UI::CSSClear clearType = UI::CSSClear::None, Float baseline = -1.f ); + UI::CSSClear clearType = UI::CSSClear::None, Float baseline = -1.f, + const UI::CSSBaselineAlignValue& baselineAlign = {} ); /** @brief Adds a virtual line break that is not associated with a DOM text character. */ void addLineBreak(); diff --git a/include/eepp/ui/css/propertydefinition.hpp b/include/eepp/ui/css/propertydefinition.hpp index 3790b1b48..ad4364d79 100644 --- a/include/eepp/ui/css/propertydefinition.hpp +++ b/include/eepp/ui/css/propertydefinition.hpp @@ -271,6 +271,7 @@ enum class PropertyId : Uint32 { Target = String::hash( "target" ), For = String::hash( "for" ), UnicodeRange = String::hash( "unicode-range" ), + AlignmentBaseline = String::hash( "alignment-baseline" ), }; enum class PropertyType : Uint32 { diff --git a/include/eepp/ui/css/shorthanddefinition.hpp b/include/eepp/ui/css/shorthanddefinition.hpp index e97de7db7..4ff1a2920 100644 --- a/include/eepp/ui/css/shorthanddefinition.hpp +++ b/include/eepp/ui/css/shorthanddefinition.hpp @@ -25,7 +25,19 @@ enum class ShorthandId : Uint32 { BorderRadius = String::hash( "border-radius" ), MinSize = String::hash( "min-size" ), MaxSize = String::hash( "max-size" ), - Font = String::hash( "font" ) + ListStye = String::hash( "list-style" ), + Font = String::hash( "font" ), + VerticalAlign = String::hash( "vertical-align" ), + BoxMargin = String::hash( "box-margin" ), + ForegroundRadius = String::hash( "foreground-radius" ), + RotateOriginPoint = String::hash( "rotate-origin-point" ), + Border = String::hash( "border" ), + TextShadow = String::hash( "text-shadow" ), + HintShadow = String::hash( "hint-shadow" ), + BorderLeft = String::hash( "border-left" ), + BorderRight = String::hash( "border-right" ), + BorderTop = String::hash( "border-top" ), + BorderBottom = String::hash( "border-bottom" ), }; typedef std::function( const ShorthandDefinition* shorthand, diff --git a/include/eepp/ui/css/stylesheetlength.hpp b/include/eepp/ui/css/stylesheetlength.hpp index ee832d5c6..c659e115b 100644 --- a/include/eepp/ui/css/stylesheetlength.hpp +++ b/include/eepp/ui/css/stylesheetlength.hpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace EE { namespace Graphics { class Font; @@ -40,13 +41,13 @@ class EE_API StyleSheetLength { Ch, }; - static Unit unitFromString( std::string unitStr ); + static Unit unitFromString( std::string_view unitStr ); static std::string unitToString( const Unit& unit ); - static bool isLength( const std::string& unitStr ); + static bool isLength( std::string_view unitStr ); - static bool isPercentage( const std::string& val ); + static bool isPercentage( std::string_view val ); static StyleSheetLength fromString( const std::string& str, const Float& defaultValue = 0, bool pxAsDp = false ); diff --git a/include/eepp/ui/csslayouttypes.hpp b/include/eepp/ui/csslayouttypes.hpp index f9a90debe..1c2725d92 100644 --- a/include/eepp/ui/csslayouttypes.hpp +++ b/include/eepp/ui/csslayouttypes.hpp @@ -3,6 +3,7 @@ #include #include +#include namespace EE { namespace UI { @@ -79,6 +80,37 @@ struct EE_API CSSClearHelper { static CSSClear fromString( std::string_view val ); }; +enum class CSSBaselineAlignment { + Baseline, + Sub, + Super, + TextTop, + TextBottom, + Middle, + Top, + Bottom, + Length, + Percentage, + Auto +}; + +struct EE_API CSSBaselineAlignValue { + CSSBaselineAlignment type{ CSSBaselineAlignment::Baseline }; + Float value{ 0.f }; + + bool operator==( const CSSBaselineAlignValue& other ) const { + return type == other.type && value == other.value; + } + + bool operator!=( const CSSBaselineAlignValue& other ) const { return !( *this == other ); } +}; + +struct EE_API CSSBaselineAlignmentHelper { + static std::string_view toString( const CSSBaselineAlignValue& val ); + + static CSSBaselineAlignValue fromKeyword( std::string_view val ); +}; + }} // namespace EE::UI #endif diff --git a/include/eepp/ui/uihtmlwidget.hpp b/include/eepp/ui/uihtmlwidget.hpp index 5b0a9cf31..d6a33b121 100644 --- a/include/eepp/ui/uihtmlwidget.hpp +++ b/include/eepp/ui/uihtmlwidget.hpp @@ -46,6 +46,10 @@ class EE_API UIHTMLWidget : public UILayout { void setCSSClear( CSSClear cssClear ); + const CSSBaselineAlignValue& getBaselineAlign() const { return mBaselineAlign; } + + void setBaselineAlign( const CSSBaselineAlignValue& baselineAlign ); + const Rectf& getOffsets() const { return mOffsets; } void setOffsets( const Rectf& offsets ); @@ -109,6 +113,7 @@ class EE_API UIHTMLWidget : public UILayout { CSSPosition mPosition{ CSSPosition::Static }; CSSFloat mFloat{ CSSFloat::None }; CSSClear mClear{ CSSClear::None }; + CSSBaselineAlignValue mBaselineAlign; std::string mTopEq{ "auto" }; std::string mRightEq{ "auto" }; std::string mBottomEq{ "auto" }; diff --git a/src/eepp/graphics/csslayouttypes.cpp b/src/eepp/graphics/csslayouttypes.cpp index 804f25063..8d7c38c29 100644 --- a/src/eepp/graphics/csslayouttypes.cpp +++ b/src/eepp/graphics/csslayouttypes.cpp @@ -1,3 +1,4 @@ +#include #include namespace EE { namespace UI { @@ -195,4 +196,76 @@ CSSClear CSSClearHelper::fromString( std::string_view val ) { return CSSClear::None; } +std::string_view CSSBaselineAlignmentHelper::toString( const CSSBaselineAlignValue& val ) { + switch ( val.type ) { + case CSSBaselineAlignment::Sub: + return "sub"; + case CSSBaselineAlignment::Super: + return "super"; + case CSSBaselineAlignment::TextTop: + return "text-top"; + case CSSBaselineAlignment::TextBottom: + return "text-bottom"; + case CSSBaselineAlignment::Middle: + return "middle"; + case CSSBaselineAlignment::Top: + return "top"; + case CSSBaselineAlignment::Bottom: + return "bottom"; + case CSSBaselineAlignment::Length: + return "length"; + case CSSBaselineAlignment::Percentage: + return "percentage"; + case CSSBaselineAlignment::Auto: + return "auto"; + case CSSBaselineAlignment::Baseline: + default: + return "baseline"; + } +} + +CSSBaselineAlignValue CSSBaselineAlignmentHelper::fromKeyword( std::string_view val ) { + CSSBaselineAlignValue out; + + switch ( String::hashToLower( val ) ) { + case String::hash( "auto" ): + out.type = CSSBaselineAlignment::Auto; + break; + case String::hash( "sub" ): + out.type = CSSBaselineAlignment::Sub; + break; + case String::hash( "super" ): + out.type = CSSBaselineAlignment::Super; + break; + case String::hash( "text-top" ): + case String::hash( "text-before-edge" ): + case String::hash( "before-edge" ): + case String::hash( "hanging" ): + out.type = CSSBaselineAlignment::TextTop; + break; + case String::hash( "text-bottom" ): + case String::hash( "text-after-edge" ): + case String::hash( "after-edge" ): + case String::hash( "mathematical" ): + out.type = CSSBaselineAlignment::TextBottom; + break; + case String::hash( "middle" ): + case String::hash( "central" ): + out.type = CSSBaselineAlignment::Middle; + break; + case String::hash( "top" ): + out.type = CSSBaselineAlignment::Top; + break; + case String::hash( "bottom" ): + out.type = CSSBaselineAlignment::Bottom; + break; + case String::hash( "baseline" ): + default: + out.type = CSSBaselineAlignment::Baseline; + break; + } + + return out; +} + }} // namespace EE::UI diff --git a/src/eepp/graphics/richtext.cpp b/src/eepp/graphics/richtext.cpp index e33c2909a..761c3b3e1 100644 --- a/src/eepp/graphics/richtext.cpp +++ b/src/eepp/graphics/richtext.cpp @@ -7,6 +7,80 @@ namespace EE { namespace Graphics { +static Float getSpanLineHeight( const RichText::SpanBlock& spanBlock ) { + const auto& text = spanBlock.text; + if ( !text || !text->getFontStyleConfig().Font ) + return spanBlock.lineHeight; + const auto& fontStyle = text->getFontStyleConfig(); + return spanBlock.lineHeight > 0 ? spanBlock.lineHeight + : fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ); +} + +static Float getTextBaseline( const RichText::SpanBlock& spanBlock ) { + const auto& text = spanBlock.text; + if ( !text || !text->getFontStyleConfig().Font ) + return 0.f; + const auto& fontStyle = text->getFontStyleConfig(); + Float fontLineSpacing = fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ); + Float lineHeight = getSpanLineHeight( spanBlock ); + Float leading = eemax( 0.f, lineHeight - fontLineSpacing ) * 0.5f; + return leading + fontStyle.Font->getAscent( fontStyle.CharacterSize ); +} + +static Float getFontXHeight( const FontStyleConfig& fontStyle ) { + if ( fontStyle.Font ) { + Glyph glyph = fontStyle.Font->getGlyph( 'x', fontStyle.CharacterSize, false, false ); + Float xHeight = eemax( 0.f, -glyph.bounds.Top ); + if ( xHeight > 0 ) + return xHeight; + } + return fontStyle.CharacterSize * 0.5f; +} + +static Float getParentAscent( const FontStyleConfig& fontStyle ) { + return fontStyle.Font ? fontStyle.Font->getAscent( fontStyle.CharacterSize ) + : static_cast( fontStyle.CharacterSize ); +} + +static Float getParentDescent( const FontStyleConfig& fontStyle ) { + if ( !fontStyle.Font ) + return 0.f; + return eemax( 0.f, fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ) - + fontStyle.Font->getAscent( fontStyle.CharacterSize ) ); +} + +static Float getBaselineAlignedOffset( const RichText::RenderParagraph& line, const Sizef& size, + Float naturalBaseline, Float ownLineHeight, + const UI::CSSBaselineAlignValue& align, + const FontStyleConfig& parentFontStyle ) { + Float baseline = line.maxAscent; + switch ( align.type ) { + case UI::CSSBaselineAlignment::Sub: + return baseline - naturalBaseline + parentFontStyle.CharacterSize * 0.2f; + case UI::CSSBaselineAlignment::Super: + return baseline - naturalBaseline - parentFontStyle.CharacterSize * 0.33f; + case UI::CSSBaselineAlignment::TextTop: + return baseline - getParentAscent( parentFontStyle ); + case UI::CSSBaselineAlignment::TextBottom: + return baseline + getParentDescent( parentFontStyle ) - size.getHeight(); + case UI::CSSBaselineAlignment::Middle: + return getParentAscent( parentFontStyle ) + getFontXHeight( parentFontStyle ) * 0.5f - + size.getHeight() * 0.5f; + case UI::CSSBaselineAlignment::Top: + return 0.f; + case UI::CSSBaselineAlignment::Bottom: + return line.height - size.getHeight(); + case UI::CSSBaselineAlignment::Length: + return baseline - naturalBaseline - align.value; + case UI::CSSBaselineAlignment::Percentage: + return baseline - naturalBaseline - ownLineHeight * align.value / 100.f; + case UI::CSSBaselineAlignment::Auto: + case UI::CSSBaselineAlignment::Baseline: + default: + return baseline - naturalBaseline; + } +} + RichText* RichText::New() { return eeNew( RichText, () ); } @@ -302,14 +376,15 @@ Sizef RichText::getPixelsSize() { } void RichText::addSpan( const String& text, const FontStyleConfig& style, const Rectf& margin, - const Rectf& padding, Float lineHeight ) { + const Rectf& padding, Float lineHeight, + const UI::CSSBaselineAlignValue& baselineAlign ) { if ( text.empty() && margin == Rectf::Zero && padding == Rectf::Zero && lineHeight == 0 ) return; auto span = std::make_shared(); span->setString( text ); span->setStyleConfig( style ); - mBlocks.push_back( SpanBlock{ span, margin, padding, lineHeight } ); + mBlocks.push_back( SpanBlock{ span, margin, padding, lineHeight, baselineAlign } ); invalidateLayout(); } @@ -321,15 +396,16 @@ void RichText::addDrawable( std::shared_ptr drawable ) { } void RichText::addCustomSize( const Sizef& size, UI::CSSFloat floatType, UI::CSSClear clearType, - Float baseline ) { + Float baseline, const UI::CSSBaselineAlignValue& baselineAlign ) { mBlocks.push_back( CustomBlock{ size, floatType, clearType, - baseline >= 0.f ? baseline : size.getHeight(), false } ); + baseline >= 0.f ? baseline : size.getHeight(), false, + baselineAlign } ); invalidateLayout(); } void RichText::addLineBreak() { mBlocks.push_back( - CustomBlock{ Sizef::Zero, UI::CSSFloat::None, UI::CSSClear::None, 0.f, true } ); + CustomBlock{ Sizef::Zero, UI::CSSFloat::None, UI::CSSClear::None, 0.f, true, {} } ); invalidateLayout(); } @@ -537,16 +613,13 @@ void RichText::updateLayout() { span->getString().substr( startIdx, endIdx - startIdx ) ); renderSpanText->setStyleConfig( fontStyle ); - Float ascent = fontStyle.Font->getAscent( fontStyle.CharacterSize ); - Float height = - pText->lineHeight > 0 - ? pText->lineHeight - : fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ); + Float height = getSpanLineHeight( *pText ); + Float ascent = getTextBaseline( *pText ); Float spanWidth = renderSpanText->getTextWidth(); RenderSpan renderSpan{ SpanBlock{ renderSpanText, pText->margin, pText->padding, - pText->lineHeight }, + pText->lineHeight, pText->baselineAlign }, { curX, 0 }, Sizef( spanWidth, height ), curCharIdx, @@ -669,26 +742,40 @@ void RichText::updateLayout() { } Float maxLineHeight = 0; + Float minLineTop = 0; for ( auto& span : line.spans ) { if ( auto pText = std::get_if( &span.block ) ) { - auto& textBlock = pText->text; - Float offsetY = line.maxAscent - textBlock->getCharacterSize(); + Float baseline = getTextBaseline( *pText ); + Float offsetY = getBaselineAlignedOffset( line, span.size, baseline, + getSpanLineHeight( *pText ), + pText->baselineAlign, mDefaultStyle ); span.position.x += xOffset; span.position.y = offsetY; + minLineTop = std::min( minLineTop, offsetY ); maxLineHeight = std::max( maxLineHeight, offsetY + span.size.getHeight() ); } else { Float baseline = span.size.getHeight(); - if ( auto pSize = std::get_if( &span.block ) ) + UI::CSSBaselineAlignValue baselineAlign; + if ( auto pSize = std::get_if( &span.block ) ) { baseline = pSize->baseline; - Float offsetY = line.maxAscent - baseline; - if ( offsetY < 0 ) - offsetY = 0; + baselineAlign = pSize->baselineAlign; + } + Float offsetY = + getBaselineAlignedOffset( line, span.size, baseline, span.size.getHeight(), + baselineAlign, mDefaultStyle ); span.position.x += xOffset; span.position.y = offsetY; + minLineTop = std::min( minLineTop, offsetY ); maxLineHeight = std::max( maxLineHeight, offsetY + span.size.getHeight() ); } } + if ( minLineTop < 0 ) { + for ( auto& span : line.spans ) + span.position.y -= minLineTop; + maxLineHeight -= minLineTop; + } + line.height = std::max( line.height, maxLineHeight ); if ( mLineHeight > 0 ) line.height = std::max( line.height, mLineHeight ); @@ -837,15 +924,13 @@ void RichText::updateLayout() { span->getString().substr( startIdx, endIdx - startIdx ) ); renderSpanText->setStyleConfig( fontStyle ); - Float ascent = fontStyle.Font->getAscent( fontStyle.CharacterSize ); - Float height = pText->lineHeight > 0 - ? pText->lineHeight - : fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ); + Float height = getSpanLineHeight( *pText ); + Float ascent = getTextBaseline( *pText ); Float spanWidth = renderSpanText->getTextWidth(); RenderSpan renderSpan{ - SpanBlock{ renderSpanText, pText->margin, pText->padding, - pText->lineHeight }, + SpanBlock{ renderSpanText, pText->margin, pText->padding, pText->lineHeight, + pText->baselineAlign }, { curX, 0 }, Sizef( spanWidth, height ), curCharIdx, @@ -1074,6 +1159,7 @@ void RichText::updateLayout() { } Float maxLineHeight = 0; + Float minLineTop = 0; for ( auto& span : line.spans ) { bool isFloat = false; if ( auto pSize = std::get_if( &span.block ) ) { @@ -1081,18 +1167,24 @@ void RichText::updateLayout() { isFloat = true; } if ( auto pText = std::get_if( &span.block ) ) { - auto& textBlock = pText->text; - Float offsetY = line.maxAscent - textBlock->getCharacterSize(); + Float baseline = getTextBaseline( *pText ); + Float offsetY = getBaselineAlignedOffset( line, span.size, baseline, + getSpanLineHeight( *pText ), + pText->baselineAlign, mDefaultStyle ); span.position.x += xOffset; span.position.y = offsetY; + minLineTop = std::min( minLineTop, offsetY ); maxLineHeight = std::max( maxLineHeight, offsetY + span.size.getHeight() ); } else { Float baseline = span.size.getHeight(); - if ( auto pSize = std::get_if( &span.block ) ) + UI::CSSBaselineAlignValue baselineAlign; + if ( auto pSize = std::get_if( &span.block ) ) { baseline = pSize->baseline; - Float offsetY = line.maxAscent - baseline; - if ( offsetY < 0 ) - offsetY = 0; + baselineAlign = pSize->baselineAlign; + } + Float offsetY = + getBaselineAlignedOffset( line, span.size, baseline, span.size.getHeight(), + baselineAlign, mDefaultStyle ); // Float spans keep their edge-aligned x; only inline-flow spans shift. if ( isFloat ) { span.position.y = 0; @@ -1101,10 +1193,22 @@ void RichText::updateLayout() { span.position.x += xOffset; } span.position.y = offsetY; + minLineTop = std::min( minLineTop, offsetY ); maxLineHeight = std::max( maxLineHeight, offsetY + span.size.getHeight() ); } } + if ( minLineTop < 0 ) { + for ( auto& span : line.spans ) { + bool isFloat = false; + if ( auto pSize = std::get_if( &span.block ) ) + isFloat = pSize->floatType != UI::CSSFloat::None; + if ( !isFloat ) + span.position.y -= minLineTop; + } + maxLineHeight -= minLineTop; + } + line.height = std::max( line.height, maxLineHeight ); if ( mLineHeight > 0 ) line.height = std::max( line.height, mLineHeight ); diff --git a/src/eepp/ui/blocklayouter.cpp b/src/eepp/ui/blocklayouter.cpp index cde22a0fb..8a6b67c66 100644 --- a/src/eepp/ui/blocklayouter.cpp +++ b/src/eepp/ui/blocklayouter.cpp @@ -361,7 +361,7 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) { mContainer->getPixelsContentOffset().Left - mContainer->getPixelsContentOffset().Right - margin.Left - margin.Right ); - if ( contentWidth != widget->getPixelsSize().getWidth() ) { + if ( widget->getPixelsSize().getWidth() == 0 && contentWidth > 0 ) { widget->setPixelsSize( contentWidth, widget->getPixelsSize().getHeight() ); mResizedCount++; diff --git a/src/eepp/ui/css/stylesheetlength.cpp b/src/eepp/ui/css/stylesheetlength.cpp index e0b0fb945..46b54c477 100644 --- a/src/eepp/ui/css/stylesheetlength.cpp +++ b/src/eepp/ui/css/stylesheetlength.cpp @@ -75,9 +75,8 @@ static PercentagePositions isPercentagePosition( const String::HashType& strHash return PercentagePositions::None; } -StyleSheetLength::Unit StyleSheetLength::unitFromString( std::string unitStr ) { - String::toLowerInPlace( unitStr ); - switch ( String::hash( unitStr ) ) { +StyleSheetLength::Unit StyleSheetLength::unitFromString( std::string_view unitStr ) { + switch ( String::hashToLower( unitStr ) ) { case UnitHashes::Percentage: return Unit::Percentage; case UnitHashes::Dp: @@ -172,24 +171,54 @@ std::string StyleSheetLength::unitToString( const StyleSheetLength::Unit& unit ) return "px"; } -bool StyleSheetLength::isLength( const std::string& unitStr ) { - LuaPattern ptrn( "(-?%d+[%d%.]*)([eE][-+]?%d+)?(%w*)" ); - PatternMatcher::Range matches[4]; - if ( ptrn.matches( unitStr, matches ) ) { - if ( matches[3].isValid() ) { - std::string unit = - unitStr.substr( matches[3].start, matches[3].end - matches[3].start ); - auto unitType = unitFromString( unit ); - if ( unitType != StyleSheetLength::Unit::Px || unit == "px" ) - return true; - return false; +bool StyleSheetLength::isLength( std::string_view unitStr ) { + if ( unitStr.empty() ) + return false; + + size_t pos = 0; + if ( unitStr[pos] == '-' || unitStr[pos] == '+' ) + pos++; + + bool hasDigit = false; + bool hasDot = false; + while ( pos < unitStr.size() ) { + char c = unitStr[pos]; + if ( c >= '0' && c <= '9' ) { + hasDigit = true; + pos++; + } else if ( c == '.' && !hasDot ) { + hasDot = true; + pos++; + } else { + break; } - return true; } - return false; + + if ( !hasDigit ) + return false; + + if ( pos < unitStr.size() && ( unitStr[pos] == 'e' || unitStr[pos] == 'E' ) ) { + size_t expPos = pos + 1; + if ( expPos < unitStr.size() && ( unitStr[expPos] == '-' || unitStr[expPos] == '+' ) ) + expPos++; + bool hasExpDigit = false; + while ( expPos < unitStr.size() && unitStr[expPos] >= '0' && unitStr[expPos] <= '9' ) { + hasExpDigit = true; + expPos++; + } + if ( hasExpDigit ) + pos = expPos; + } + + std::string_view unit = unitStr.substr( pos ); + if ( unit.empty() ) + return true; + + auto unitType = unitFromString( unit ); + return unitType != StyleSheetLength::Unit::Px || unit == "px"; } -bool StyleSheetLength::isPercentage( const std::string& val ) { +bool StyleSheetLength::isPercentage( std::string_view val ) { return !val.empty() && val.back() == '%'; } @@ -338,7 +367,7 @@ StyleSheetLength& StyleSheetLength::operator=( const StyleSheetLength& val ) { StyleSheetLength StyleSheetLength::fromString( const std::string& str, const Float& defaultValue, bool pxAsDp ) { - PercentagePositions isPercentage = isPercentagePosition( String::hash( str ) ); + PercentagePositions isPercentage = isPercentagePosition( String::hashToLower( str ) ); if ( PercentagePositions::None != isPercentage ) return fromString( positionToPercentage( isPercentage ), defaultValue ); diff --git a/src/eepp/ui/css/stylesheetspecification.cpp b/src/eepp/ui/css/stylesheetspecification.cpp index f9b08137d..6569d5a6f 100644 --- a/src/eepp/ui/css/stylesheetspecification.cpp +++ b/src/eepp/ui/css/stylesheetspecification.cpp @@ -496,6 +496,7 @@ void StyleSheetSpecification::registerDefaultProperties() { .setType( PropertyType::String ); registerProperty( "target", "_self" ).setType( PropertyType::String ); registerProperty( "unicode-range", "" ).setType( PropertyType::String ); + registerProperty( "alignment-baseline", "baseline" ).setType( PropertyType::String ); // Shorthands registerShorthand( "margin", { "margin-top", "margin-right", "margin-bottom", "margin-left" }, @@ -566,6 +567,7 @@ void StyleSheetSpecification::registerDefaultProperties() { registerShorthand( "font", { "font-style", "font-weight", "font-size", "line-height", "font-family" }, "font" ); + registerShorthand( "vertical-align", { "alignment-baseline" }, "vertical-align" ); } void StyleSheetSpecification::registerNodeSelector( const std::string& name, @@ -846,6 +848,18 @@ void StyleSheetSpecification::registerDefaultShorthandParsers() { return properties; }; + mShorthandParsers["vertical-align"] = + []( const ShorthandDefinition* shorthand, + std::string value ) -> std::vector { + value = String::trim( value ); + if ( value.empty() ) + return {}; + const std::vector& propNames = shorthand->getProperties(); + if ( propNames.empty() ) + return {}; + return { StyleSheetProperty( propNames[0], value ) }; + }; + mShorthandParsers["vector2"] = []( const ShorthandDefinition* shorthand, std::string value ) -> std::vector { value = String::trim( value ); diff --git a/src/eepp/ui/uihtmlwidget.cpp b/src/eepp/ui/uihtmlwidget.cpp index f4ae90722..f6183b40b 100644 --- a/src/eepp/ui/uihtmlwidget.cpp +++ b/src/eepp/ui/uihtmlwidget.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -30,6 +31,37 @@ static std::string normalizeDataPropertyName( std::string_view name ) { return normalizedName; } +static CSSBaselineAlignValue parseBaselineAlign( UIHTMLWidget* widget, + const StyleSheetProperty& property ) { + std::string_view val = property.value(); + auto isSpace = []( char c ) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; + }; + while ( !val.empty() && isSpace( val.front() ) ) + val.remove_prefix( 1 ); + while ( !val.empty() && isSpace( val.back() ) ) + val.remove_suffix( 1 ); + if ( val.empty() ) + return {}; + + if ( CSS::StyleSheetLength::isPercentage( val ) ) { + CSSBaselineAlignValue out; + out.type = CSSBaselineAlignment::Percentage; + out.value = CSS::StyleSheetLength::fromString( std::string( val ) ).getValue(); + return out; + } + + if ( CSS::StyleSheetLength::isLength( val ) ) { + CSSBaselineAlignValue out; + out.type = CSSBaselineAlignment::Length; + out.value = + widget->lengthFromValue( std::string( val ), CSS::PropertyRelativeTarget::None ); + return out; + } + + return CSSBaselineAlignmentHelper::fromKeyword( val ); +} + UIHTMLWidget* UIHTMLWidget::New() { return eeNew( UIHTMLWidget, () ); } @@ -122,6 +154,13 @@ void UIHTMLWidget::setCSSClear( CSSClear cssClear ) { } } +void UIHTMLWidget::setBaselineAlign( const CSSBaselineAlignValue& baselineAlign ) { + if ( mBaselineAlign != baselineAlign ) { + mBaselineAlign = baselineAlign; + notifyLayoutAttrChange(); + } +} + void UIHTMLWidget::setOffsets( const Rectf& offsets ) { if ( mOffsets != offsets ) { mOffsets = offsets; @@ -139,9 +178,11 @@ void UIHTMLWidget::setZIndex( int zIndex ) { std::vector UIHTMLWidget::getPropertiesImplemented() const { auto props = UILayout::getPropertiesImplemented(); - auto local = { PropertyId::Display, PropertyId::Position, PropertyId::Float, - PropertyId::Clear, PropertyId::Top, PropertyId::Right, - PropertyId::Bottom, PropertyId::Left, PropertyId::ZIndex }; + auto local = { PropertyId::Display, PropertyId::Position, + PropertyId::Float, PropertyId::Clear, + PropertyId::Top, PropertyId::Right, + PropertyId::Bottom, PropertyId::Left, + PropertyId::ZIndex, PropertyId::AlignmentBaseline }; props.insert( props.end(), local.begin(), local.end() ); return props; } @@ -170,6 +211,8 @@ std::string UIHTMLWidget::getPropertyString( const PropertyDefinition* propertyD return mLeftEq; case PropertyId::ZIndex: return String::toString( mZIndex ); + case PropertyId::AlignmentBaseline: + return std::string( CSSBaselineAlignmentHelper::toString( mBaselineAlign ) ); default: return UILayout::getPropertyString( propertyDef ); } @@ -220,6 +263,10 @@ bool UIHTMLWidget::applyProperty( const StyleSheetProperty& attribute ) { notifyLayoutAttrChange(); return true; } + case PropertyId::AlignmentBaseline: { + setBaselineAlign( parseBaselineAlign( this, attribute ) ); + return true; + } default: break; } diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index cb063e1a9..7b9ba25f9 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -842,6 +842,33 @@ static Float getCustomBlockBaseline( UIWidget* widget, const Sizef& widgetSize, return fallbackBaseline; } +static CSSBaselineAlignValue getWidgetBaselineAlign( UIWidget* widget ) { + if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) + return widget->asType()->getBaselineAlign(); + return {}; +} + +static bool isDefaultBaselineAlign( const CSSBaselineAlignValue& align ) { + return align.type == CSSBaselineAlignment::Baseline || align.type == CSSBaselineAlignment::Auto; +} + +static CSSBaselineAlignValue getEffectiveInlineBaselineAlign( Node* node, UILayout* container ) { + // vertical-align is not inherited, but nested inline boxes can be flattened into a single + // RichText span. Preserve the nearest explicit inline box alignment so the generated span + // still represents the parent inline box being aligned. + for ( Node* cur = node; cur && cur != container; cur = cur->getParent() ) { + if ( !cur->isWidget() ) + continue; + UIWidget* widget = cur->asType(); + if ( !widget->isInlineDisplay() ) + continue; + CSSBaselineAlignValue align = getWidgetBaselineAlign( widget ); + if ( !isDefaultBaselineAlign( align ) ) + return align; + } + return {}; +} + void UIRichText::rebuildRichText( UILayout* container, RichText& richText, IntrinsicMode mode ) { richText.clear(); if ( container->isType( UI_TYPE_RICHTEXT ) ) { @@ -899,7 +926,8 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri String::View selfText = selfSpan->getText().view(); FontStyleConfig style = selfSpan->getFontStyleConfig(); style.BackgroundColor = Color::Transparent; - richText.addSpan( selfText, style ); + richText.addSpan( selfText, style, Rectf::Zero, Rectf::Zero, 0, + selfSpan->getBaselineAlign() ); if ( shouldCollapse ) lastSpanEndsWithSpace = !selfText.empty() && selfText.back() == ' '; } @@ -983,7 +1011,11 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri } else { style = richText.getFontStyleConfig(); } - richText.addSpan( text, style ); + CSSBaselineAlignValue baselineAlign; + if ( node->getParent()->isWidget() ) + baselineAlign = getEffectiveInlineBaselineAlign( + node->getParent()->asType(), container ); + richText.addSpan( text, style, Rectf::Zero, Rectf::Zero, 0, baselineAlign ); return; } @@ -1031,7 +1063,8 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri if ( !spanText.empty() ) { richText.addSpan( spanText, span->getFontStyleConfig(), margin, padding, - spanLineHeight ); + spanLineHeight, + getEffectiveInlineBaselineAlign( span, container ) ); span->setLayoutCharCount( spanText.length() ); if ( shouldCollapse ) lastSpanEndsWithSpace = spanText.back() == ' '; @@ -1041,7 +1074,8 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri } else if ( margin.Left > 0 || margin.Top > 0 || padding.Left > 0 || padding.Top > 0 ) { Rectf leftOnly( margin.Left, margin.Top, 0, 0 ); Rectf padLeftOnly( padding.Left, padding.Top, 0, 0 ); - richText.addSpan( "", span->getFontStyleConfig(), leftOnly, padLeftOnly ); + richText.addSpan( "", span->getFontStyleConfig(), leftOnly, padLeftOnly, 0, + getEffectiveInlineBaselineAlign( span, container ) ); } Node* spanChild = span->getFirstChild(); @@ -1057,7 +1091,8 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri padding.Bottom > 0 ) ) { Rectf rightOnly( 0, 0, margin.Right, margin.Bottom ); Rectf padRightOnly( 0, 0, padding.Right, padding.Bottom ); - richText.addSpan( "", span->getFontStyleConfig(), rightOnly, padRightOnly ); + richText.addSpan( "", span->getFontStyleConfig(), rightOnly, padRightOnly, 0, + getEffectiveInlineBaselineAlign( span, container ) ); } if ( shouldCollapse && span->isInlineBlock() ) @@ -1142,7 +1177,8 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri Sizef customSize( w + margin.Left + margin.Right, size.getHeight() + margin.Top + margin.Bottom ); richText.addCustomSize( customSize, floatType, clearType, - getCustomBlockBaseline( widget, size, margin ) ); + getCustomBlockBaseline( widget, size, margin ), + getWidgetBaselineAlign( widget ) ); if ( widget->isType( UI_TYPE_TEXTSPAN ) && widget->asType()->isInlineBlock() && @@ -1250,7 +1286,10 @@ Float UIRichText::getMaxIntrinsicWidth() const { Uint32 UIRichText::onMessage( const NodeMessage* Msg ) { switch ( Msg->getMsg() ) { case NodeMessage::LayoutAttributeChange: { - if ( Msg->getSender() != this && !mPacking ) { + bool packing = isPacking(); + if ( packing ) + return 1; + if ( Msg->getSender() != this ) { invalidateIntrinsicSize(); notifyLayoutAttrChangeParent(); } diff --git a/src/tests/unit_tests/richtext_tests.cpp b/src/tests/unit_tests/richtext_tests.cpp index 483c3cdb3..cc14d661f 100644 --- a/src/tests/unit_tests/richtext_tests.cpp +++ b/src/tests/unit_tests/richtext_tests.cpp @@ -203,13 +203,11 @@ UTEST( RichText, BaselineAlignment ) { const auto& largeSpan = lines[0].spans[0]; const auto& smallSpan = lines[0].spans[1]; - // Large span should be at the top of the line (offset 0 relative to ascent difference) - // Small span should be pushed down Float largeAscent = font->getAscent( 30 ); + Float smallAscent = font->getAscent( 12 ); - // Expected offsets - Float expectedLargeY = largeAscent - 30; - Float expectedSmallY = largeAscent - 12; + Float expectedLargeY = 0; + Float expectedSmallY = largeAscent - smallAscent; EXPECT_NEAR( largeSpan.position.y, expectedLargeY, 0.001f ); EXPECT_NEAR( smallSpan.position.y, expectedSmallY, 0.001f ); @@ -219,6 +217,63 @@ UTEST( RichText, BaselineAlignment ) { Engine::destroySingleton(); } +UTEST( RichText, VerticalAlignCustomBlocks ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Vertical Align", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font->loaded() ); + + RichText baselineRt; + baselineRt.getFontStyleConfig().Font = font; + baselineRt.getFontStyleConfig().CharacterSize = 20; + baselineRt.addSpan( "A", nullptr, 20 ); + baselineRt.addCustomSize( Sizef( 20, 20 ), CSSFloat::None, CSSClear::None, 10.f ); + baselineRt.getSize(); + ASSERT_EQ( baselineRt.getLines().front().spans.size(), (size_t)2 ); + Float baselineY = baselineRt.getLines().front().spans[1].position.y; + + CSSBaselineAlignValue middleAlign; + middleAlign.type = CSSBaselineAlignment::Middle; + RichText middleRt; + middleRt.getFontStyleConfig().Font = font; + middleRt.getFontStyleConfig().CharacterSize = 20; + middleRt.addSpan( "A", nullptr, 20 ); + middleRt.addCustomSize( Sizef( 20, 20 ), CSSFloat::None, CSSClear::None, 10.f, middleAlign ); + middleRt.getSize(); + ASSERT_EQ( middleRt.getLines().front().spans.size(), (size_t)2 ); + EXPECT_GT( middleRt.getLines().front().spans[1].position.y, baselineY ); + + CSSBaselineAlignValue lengthAlign; + lengthAlign.type = CSSBaselineAlignment::Length; + lengthAlign.value = 4.f; + RichText lengthRt; + lengthRt.getFontStyleConfig().Font = font; + lengthRt.getFontStyleConfig().CharacterSize = 20; + lengthRt.addSpan( "A", nullptr, 20 ); + lengthRt.addCustomSize( Sizef( 20, 20 ), CSSFloat::None, CSSClear::None, 10.f, lengthAlign ); + lengthRt.getSize(); + ASSERT_EQ( lengthRt.getLines().front().spans.size(), (size_t)2 ); + EXPECT_NEAR( lengthRt.getLines().front().spans[1].position.y, baselineY - 4.f, 0.001f ); + + CSSBaselineAlignValue percentAlign; + percentAlign.type = CSSBaselineAlignment::Percentage; + percentAlign.value = 50.f; + RichText percentRt; + percentRt.getFontStyleConfig().Font = font; + percentRt.getFontStyleConfig().CharacterSize = 20; + percentRt.addSpan( "A", nullptr, 20 ); + percentRt.addCustomSize( Sizef( 20, 20 ), CSSFloat::None, CSSClear::None, 10.f, percentAlign ); + percentRt.getSize(); + ASSERT_EQ( percentRt.getLines().front().spans.size(), (size_t)2 ); + EXPECT_NEAR( percentRt.getLines().front().spans[1].position.y, baselineY - 10.f, 0.001f ); + + Engine::destroySingleton(); +} + UTEST( RichText, CustomBlockBaselineAlignment ) { Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Custom Block Baseline", WindowStyle::Default, WindowBackend::Default, @@ -241,10 +296,9 @@ UTEST( RichText, CustomBlockBaselineAlignment ) { ASSERT_EQ( lines.size(), (size_t)1 ); ASSERT_EQ( lines[0].spans.size(), (size_t)3 ); - const auto& smallSpan = lines[0].spans[1]; const auto& customSpan = lines[0].spans[2]; - EXPECT_NEAR( customSpan.position.y, smallSpan.position.y, 0.001f ); + EXPECT_NEAR( customSpan.position.y, font->getAscent( 30 ) - 12.f, 0.001f ); EXPECT_GT( customSpan.position.y, 0.f ); Engine::destroySingleton(); diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp index 38aa361da..41b966f61 100644 --- a/src/tests/unit_tests/uihtml_tests.cpp +++ b/src/tests/unit_tests/uihtml_tests.cpp @@ -315,6 +315,59 @@ UTEST( UIRichText, anchorPaddingLineHeight ) { Engine::destroySingleton(); } +UTEST( UIHTML, InlineBaselineAlignmentProperties ) { + Engine::instance()->createWindow( WindowSettings( 800, 600, "Inline Baseline Alignment Test", + WindowStyle::Default, WindowBackend::Default, + 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ); + font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" ); + ASSERT_TRUE( font != nullptr && font->loaded() ); + FontFamily::loadFromRegular( font ); + + UI::UISceneNode* sceneNode = UI::UISceneNode::New(); + SceneManager::instance()->add( sceneNode ); + sceneNode->getUIThemeManager()->setDefaultFont( font ); + sceneNode->loadLayoutFromString( R"html( + + + AX + + + AX + + + AX + + + )html" ); + sceneNode->updateDirtyLayouts(); + + auto* baseline = sceneNode->getRoot()->find( "baseline" )->asType(); + auto* middle = sceneNode->getRoot()->find( "middle" )->asType(); + auto* alignmentMiddle = sceneNode->getRoot()->find( "alignment_middle" )->asType(); + auto* baselineBox = sceneNode->getRoot()->find( "base_box" )->asType(); + auto* middleBox = sceneNode->getRoot()->find( "middle_box" )->asType(); + auto* middleChild = sceneNode->getRoot()->find( "middle_child" )->asType(); + auto* alignmentBox = sceneNode->getRoot()->find( "alignment_box" )->asType(); + ASSERT_TRUE( baseline != nullptr ); + ASSERT_TRUE( middle != nullptr ); + ASSERT_TRUE( alignmentMiddle != nullptr ); + ASSERT_TRUE( baselineBox != nullptr ); + ASSERT_TRUE( middleBox != nullptr ); + ASSERT_TRUE( middleChild != nullptr ); + ASSERT_TRUE( alignmentBox != nullptr ); + + EXPECT_EQ( baselineBox->getBaselineAlign().type, CSSBaselineAlignment::Baseline ); + EXPECT_EQ( middleBox->getBaselineAlign().type, CSSBaselineAlignment::Middle ); + EXPECT_EQ( middleChild->getBaselineAlign().type, CSSBaselineAlignment::Baseline ); + EXPECT_EQ( alignmentBox->getBaselineAlign().type, CSSBaselineAlignment::Middle ); + + Engine::destroySingleton(); +} + UTEST( UIHTMLTable, complexLayout3 ) { auto win = Engine::instance()->createWindow( WindowSettings( 1024, 650, "HTML Tables Test 3", WindowStyle::Default, @@ -2162,6 +2215,37 @@ UTEST( UIBackground, InlineBlockImageFixedSize ) { Engine::destroySingleton(); } +UTEST( UIHTML, RedditHeaderPagenameBottomAlign ) { + auto win = Engine::instance()->createWindow( + WindowSettings( 1024, 653, "reddit header pagename bottom align", WindowStyle::Default, + WindowBackend::Default, 32, {}, 1, false, true ), + ContextSettings( false, 0, 0, GLv_default, true, false ) ); + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + UI::UISceneNode* sceneNode = init_test_inline_block(); + sceneNode->setURI( "file://" + Sys::getProcessPath() + "assets/html/" ); + + std::string html; + FileSystem::fileGet( "assets/html/reddit_header.html", html ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + win->getInput()->update(); + SceneManager::instance()->update(); + + auto* headerLeft = sceneNode->getRoot()->find( "header-bottom-left" )->asType(); + auto* logo = sceneNode->getRoot()->find( "header-img" )->asType(); + auto* page = sceneNode->getRoot()->querySelector( ".pagename" )->asType(); + ASSERT_TRUE( headerLeft != nullptr ); + ASSERT_TRUE( logo != nullptr ); + ASSERT_TRUE( page != nullptr ); + + EXPECT_EQ( logo->getBaselineAlign().type, CSSBaselineAlignment::Bottom ); + EXPECT_EQ( page->getBaselineAlign().type, CSSBaselineAlignment::Bottom ); + EXPECT_NEAR( headerLeft->getPixelsSize().getHeight(), + page->getPixelsPosition().y + page->getPixelsSize().getHeight(), 0.5f ); + + Engine::destroySingleton(); +} + UTEST( UIHTML, AnchorsSizing ) { auto win = Engine::instance()->createWindow( WindowSettings( 1024, 653, "anchors sizing", WindowStyle::Default, WindowBackend::Default,