From caf0aa707138ef09d4f2d9bbbb96ab794c0ab6d1 Mon Sep 17 00:00:00 2001 From: cadons Date: Sun, 16 Aug 2026 19:20:15 +0200 Subject: [PATCH 1/2] refactor(layout): streamline fixed and flexible column width resolution --- .../docraft_loom_weighted_distribution.h | 47 ++++++++++++ .../pipeline/docraft_loom_layout_processor.cc | 75 ++++--------------- .../docraft_loom_measure_processor.cc | 74 +++++++++++------- .../docraft_loom_weighted_distribution.cc | 62 +++++++++++++++ .../docraft_loom_measure_processor_test.cc | 47 ++++++++++++ 5 files changed, 219 insertions(+), 86 deletions(-) diff --git a/docraft/include/docraft/loom/pipeline/docraft_loom_weighted_distribution.h b/docraft/include/docraft/loom/pipeline/docraft_loom_weighted_distribution.h index c125ff5..5aa2515 100644 --- a/docraft/include/docraft/loom/pipeline/docraft_loom_weighted_distribution.h +++ b/docraft/include/docraft/loom/pipeline/docraft_loom_weighted_distribution.h @@ -39,4 +39,51 @@ namespace docraft::loom::pipeline { const std::vector& weights, int count, const std::vector& floors = {}); + + /** + * @brief Inputs for resolve_fixed_and_flexible_amounts(). Grouped into a struct + * instead of positional parameters because weights/explicit_amounts/floors are + * three same-typed `vector` in a row -- an easy place to pass the wrong one + * to the wrong slot without the compiler catching it. + */ + struct WeightedSplitRequest + { + /// Total amount (width or height) to divide among `count` items. + float available_amount = 0.0F; + /// Number of items to resolve a share for. + int count = 0; + /// Per-item weight, applied only to flexible items; a missing or non-positive + /// entry defaults to 1.0. + std::vector weights; + /// Per-item explicit override; a missing or non-positive entry means that item + /// is flexible. Empty (the default) means every item is flexible. + std::vector explicit_amounts; + /// Optional per-flexible-item floor (e.g. each item's own natural/measured + /// size on that axis); if non-empty (same size as `count`), no flexible item's + /// result is smaller than its floor. + std::vector floors; + }; + + /** + * @brief Like distribute_weighted_amounts, but items can opt out of the weighted + * split entirely: an item with a positive `explicit_amounts[i]` is "fixed" -- it + * keeps that exact value, doesn't count toward the weight pool, and its amount is + * subtracted from `available_amount` first. Only what's left over is then split by + * weight (floored per `floors`) among the remaining, flexible items -- via a plain + * distribute_weighted_amounts() call restricted to that flexible subset, so both + * functions apply the exact same weight-default and floor rules. + * + * Both table-column-width call sites need this same two-tier split: a column with + * an explicit Cell width="..." on any row must resolve to exactly that width, and + * only the other columns compete for whatever space is left. + * DocraftLoomLayoutProcessor::resolve_table_column_widths calls this once each + * cell's natural width is known (passed as `floors`); DocraftLoomMeasureProcessor + * calls it earlier, to estimate a wrap budget before any cell has been measured + * (so with no `floors`). Sharing this function is what keeps the two in sync -- + * without it, Measure's estimate can silently drift from what Layout later paints. + * @param request See WeightedSplitRequest. + * @return `request.count` resolved shares, summing to `request.available_amount` + * unless the floors force a flexible item above its weighted share. + */ + std::vector resolve_fixed_and_flexible_amounts(const WeightedSplitRequest& request); } // namespace docraft::loom::pipeline \ No newline at end of file diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc index b08be78..f12cdb3 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc @@ -627,67 +627,20 @@ namespace docraft::loom::pipeline { (2.0F * table.padding()) : sum_natural; - // A column is "fixed" if the author gave it an explicit width, "flexible" - // otherwise. Both helpers just read geometry/table -- no bookkeeping needed. - const auto& weights = table.column_weights(); - auto is_fixed = [&](int c) { return geometry.explicit_widths[static_cast(c)] > 0.0F; }; - auto column_weight = [&](int c) { - if (c >= 0 && c < static_cast(weights.size()) && weights[static_cast(c)] > 0.0F) - return weights[static_cast(c)]; - return 1.0F; // missing/non-positive weight defaults to 1.0, same as distribute_weighted_amounts() - }; - - std::vector resolved(static_cast(cols), 0.0F); - - // 1) Fixed columns keep their own width verbatim. `remaining` is what's - // actually left over for the flexible ones -- not the full available_width. - float remaining = available_width; - for (int c = 0; c < cols; ++c) - { - if (is_fixed(c)) - { - resolved[static_cast(c)] = geometry.explicit_widths[static_cast(c)]; - remaining -= resolved[static_cast(c)]; - } - } - remaining = std::max(0.0F, remaining); - - // 2) Split `remaining` among the flexible columns by weight. total_weight - // only sums flexible columns' weights, so a fixed column's weight can't - // dilute anyone else's share -- this is the actual fix (see example above). - // Each share is floored at the column's own natural width: never squeeze a - // column narrower than its content. - float total_weight = 0.0F; - for (int c = 0; c < cols; ++c) - if (!is_fixed(c)) - total_weight += column_weight(c); - - float flexible_total = 0.0F; - for (int c = 0; c < cols; ++c) - { - if (is_fixed(c)) - continue; - const float share = total_weight > 0.0F ? remaining * column_weight(c) / total_weight : 0.0F; - resolved[static_cast(c)] = - std::max(geometry.natural_widths[static_cast(c)], share); - flexible_total += resolved[static_cast(c)]; - } - - // 3) The floor in step 2 can push a column above its weighted share, so the - // flexible columns might no longer add up to `remaining`. Scale just those - // columns (fixed ones stay untouched) so the total matches available_width - // whenever the floors allow it. If the floors alone already exceed - // `remaining`, this scales below 1 and shrinks columns under their natural - // width -- content genuinely doesn't fit, but the table still stays close to - // available_width instead of overflowing it freely. - if (flexible_total > 0.0F && remaining > 0.0F) - { - const float scale = remaining / flexible_total; - for (int c = 0; c < cols; ++c) - if (!is_fixed(c)) - resolved[static_cast(c)] *= scale; - } - return resolved; + // Fixed columns (explicit_widths[c] > 0) keep their own width verbatim and + // reserve it out of available_width before flexible columns split what's left + // by weight, floored at each flexible column's own natural width so content + // never gets squeezed narrower than it needs -- see + // resolve_fixed_and_flexible_amounts for the shared fixed/flexible algorithm + // (also used by DocraftLoomMeasureProcessor's table wrap-budget estimate, so + // both agree on which columns are fixed). + return resolve_fixed_and_flexible_amounts({ + .available_amount = available_width, + .count = cols, + .weights = table.column_weights(), + .explicit_amounts = geometry.explicit_widths, + .floors = geometry.natural_widths, + }); } // Horizontal offset for content re-centered within a wider resolved column: text diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc index 7434ba1..50c4cea 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc @@ -494,38 +494,62 @@ namespace docraft::loom::pipeline { const int rows = table->row_count(); const int cols = table->column_count(); - // Best-effort per-column wrap ceiling, computed the same way Layout will later - // resolve column widths (explicit_width(), else a weight-based share, else an - // even split) -- see DocraftLoomLayoutProcessor::resolve_table_column_widths for - // the authoritative post-Measure version. This is only an upfront estimate so - // over-long cell text wraps instead of silently overflowing its column; it isn't - // pushed unconditionally (see visit(DocraftLoomTableCell*)), so it never disturbs - // the natural-width-floor sizing of cells that already fit. - std::vector column_wrap_budget(static_cast(cols), 0.0F); - if (cols > 0 && incoming_width > 0.0F) { - const float available_width = - incoming_width - (2.0F * nodes::DocraftLoomTable::kCellPaddingX) - (2.0F * table->padding()); - if (available_width > 0.0F) { - const auto shares = distribute_weighted_amounts(available_width, table->column_weights(), cols); - for (int c = 0; c < cols; ++c) { - column_wrap_budget[static_cast(c)] = - std::max(0.0F, shares[static_cast(c)] - - (2.0F * nodes::DocraftLoomTable::kCellPaddingX)); + // A column counts as "fixed" as soon as ONE of its cells sets Cell width="...", + // no matter which row -- so this scans every row before measuring any cell, + // not just the row currently being visited. Without this, a row that omits + // width() (inheriting the column from a sibling row) would look flexible here, + // and the wrap budget below would shrink that column instead of matching what + // it's actually painted at. Same idea as + // DocraftLoomLayoutProcessor::gather_table_natural_geometry's explicit_widths, + // minus natural_widths -- no cell has been measured yet at this point. + std::vector column_explicit_widths(static_cast(cols), 0.0F); + for (int r = 0; r < rows; ++r) { + for (int c = 0; c < cols; ++c) { + if (auto width = table->cell(r, c)->explicit_width()) { + column_explicit_widths[static_cast(c)] = + std::max(column_explicit_widths[static_cast(c)], *width); } } } - std::vector col_widths(static_cast(cols), 0.0F); - std::vector row_heights(static_cast(rows), 0.0F); + // Upfront estimate of each column's wrap ceiling, using the same fixed-vs- + // flexible split Layout performs for real once every cell is measured (see + // resolve_table_column_widths) -- fixed columns keep column_explicit_widths + // verbatim, flexible columns split whatever's left by weight. The one thing + // this pass can't do yet is floor flexible columns at their natural width + // (nothing has been measured), so it's an estimate, not the final word: a cell + // only wraps if its own text turns out wider than this budget (see + // visit(DocraftLoomTableCell*)), so a short cell in a wide column is never + // disturbed by an under-estimate here. + // + // resolve_fixed_and_flexible_amounts() always honors a fixed column's own + // explicit width, even when available_width below is 0 -- so a cell with its + // own width() still gets a real wrap budget with no page/content width set at + // all; only the flexible columns then get no budget (0), same as before. + float available_width = 0.0F; + if (incoming_width > 0.0F) { + available_width = std::max( + 0.0F, incoming_width - (2.0F * nodes::DocraftLoomTable::kCellPaddingX) - (2.0F * table->padding())); + } + const auto resolved_widths = resolve_fixed_and_flexible_amounts({ + .available_amount = available_width, + .count = cols, + .weights = table->column_weights(), + .explicit_amounts = column_explicit_widths, + }); + std::vector column_wrap_budget(static_cast(cols), 0.0F); + for (int c = 0; c < cols; ++c) { + column_wrap_budget[static_cast(c)] = + std::max(0.0F, resolved_widths[static_cast(c)] - + (2.0F * nodes::DocraftLoomTable::kCellPaddingX)); + } + + std::vector col_widths(static_cast(cols), 0.0F); + std::vector row_heights(static_cast(rows), 0.0F); for (int r = 0; r < rows; ++r) { for (int c = 0; c < cols; ++c) { auto cell = table->cell(r, c); - // An explicit per-cell width is a harder, more specific constraint than - // the column estimate above -- prefer it when set. - pending_cell_wrap_budget_ = - cell->explicit_width().has_value() - ? std::max(0.0F, *cell->explicit_width() - (2.0F * nodes::DocraftLoomTable::kCellPaddingX)) - : column_wrap_budget[static_cast(c)]; + pending_cell_wrap_budget_ = column_wrap_budget[static_cast(c)]; cell->accept(*this); // Cell's own measured_size already folds in its padding inset (see // DocraftLoomTableCell's own Measure visit above) -- no extra term here. diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_weighted_distribution.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_weighted_distribution.cc index 95a610f..4c1eddb 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_weighted_distribution.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_weighted_distribution.cc @@ -49,4 +49,66 @@ namespace docraft::loom::pipeline { } return resolved; } + + std::vector resolve_fixed_and_flexible_amounts(const WeightedSplitRequest& request) + { + const int count = request.count; + std::vector resolved(static_cast(std::max(count, 0)), 0.0F); + if (count <= 0) + { + return resolved; + } + + auto is_fixed = [&](int i) { + return i < static_cast(request.explicit_amounts.size()) && + request.explicit_amounts[static_cast(i)] > 0.0F; + }; + + // 1) Fixed items keep their own amount verbatim, and their share is subtracted + // from `remaining` so it's never up for grabs by the flexible items below. + // While reserving fixed items, also collect the flexible ones' own weight/floor + // into their own compacted vectors (indices 0..flexible_count-1), so step 2 can + // hand them to a plain distribute_weighted_amounts() call. + float remaining = request.available_amount; + std::vector flexible_indices; + std::vector flexible_weights; + std::vector flexible_floors; + const bool has_floors = !request.floors.empty(); + for (int i = 0; i < count; ++i) + { + if (is_fixed(i)) + { + resolved[static_cast(i)] = request.explicit_amounts[static_cast(i)]; + remaining -= resolved[static_cast(i)]; + continue; + } + flexible_indices.push_back(i); + flexible_weights.push_back( + i < static_cast(request.weights.size()) ? request.weights[static_cast(i)] : 0.0F); + if (has_floors) + flexible_floors.push_back(request.floors[static_cast(i)]); + } + remaining = std::max(0.0F, remaining); + + // 2) Split `remaining` among just the flexible items -- reusing + // distribute_weighted_amounts() here (instead of re-deriving its weight-default + // and floor logic) is what guarantees a flexible item is treated identically + // whether it's resolved through this function or a plain weighted split. + const auto flexible_shares = distribute_weighted_amounts( + remaining, flexible_weights, static_cast(flexible_indices.size()), flexible_floors); + + float flexible_total = 0.0F; + for (float share : flexible_shares) + flexible_total += share; + + // 3) A floor in step 2 can push a flexible item above its weighted share, so the + // flexible items might no longer add up to `remaining`. Scale just those items + // (fixed ones stay untouched) so the total matches `available_amount` whenever + // the floors allow it. + const float scale = (flexible_total > 0.0F && remaining > 0.0F) ? remaining / flexible_total : 1.0F; + for (std::size_t k = 0; k < flexible_indices.size(); ++k) + resolved[static_cast(flexible_indices[k])] = flexible_shares[k] * scale; + + return resolved; + } } // namespace docraft::loom::pipeline \ No newline at end of file diff --git a/docraft/test/docraft/loom/pipeline/docraft_loom_measure_processor_test.cc b/docraft/test/docraft/loom/pipeline/docraft_loom_measure_processor_test.cc index 4aeccec..65e5016 100644 --- a/docraft/test/docraft/loom/pipeline/docraft_loom_measure_processor_test.cc +++ b/docraft/test/docraft/loom/pipeline/docraft_loom_measure_processor_test.cc @@ -3,6 +3,8 @@ #include "docraft/loom/nodes/docraft_loom_circle.h" #include "docraft/loom/nodes/docraft_loom_hstack.h" #include "docraft/loom/nodes/docraft_loom_rectangle.h" +#include "docraft/loom/nodes/docraft_loom_table.h" +#include "docraft/loom/nodes/docraft_loom_table_cell.h" #include "docraft/loom/nodes/docraft_loom_text.h" #include #include @@ -167,4 +169,49 @@ namespace docraft::test { } EXPECT_EQ(reassembled, word); } + + // A column's width can come from ANY row's cell -- e.g. only the header/first row + // repeats Cell width="...", later rows omit it and just inherit the column. A cell + // with no explicit width of its own must get that column's real (sibling-set) + // width as its wrap budget, not a naive even/weighted split of the whole table that + // ignores the fixed column entirely -- that naive split under-budgeted the column, + // wrapping its text far narrower than the box it's actually painted in. + TEST_F(DocraftLoomMeasureProcessorTest, ColumnWrapBudgetHonorsExplicitWidthSetBySiblingRow) + { + using ::testing::_; + using ::testing::Return; + EXPECT_CALL(*text_backend_mock(), measure_text_width(_, _, _)).WillRepeatedly(Return(250.0F)); + EXPECT_CALL(*text_backend_mock(), measure_text_height(_, _)).WillRepeatedly(Return(10.0F)); + EXPECT_CALL(*text_backend_mock(), measure_text_ascent(_, _)).WillRepeatedly(Return(8.0F)); + EXPECT_CALL(*text_backend_mock(), measure_text_descent(_, _)).WillRepeatedly(Return(-2.0F)); + + processor()->set_content_width(400.0F); + + auto table = std::make_shared(); + table->set_padding(0.0F); + + // Row 0 fixes column 0's width at 300 via its own cell; column 1 stays flexible. + auto cell_a0 = std::make_shared(); + cell_a0->set_explicit_width(300.0F); + cell_a0->set_content(std::make_shared("short")); + auto cell_b0 = std::make_shared(); + cell_b0->set_content(std::make_shared("short")); + table->add_row({cell_a0, cell_b0}); + + // Row 1 omits width on every cell -- column 0's cell here must still be + // budgeted against the 300pt the column was fixed at by row 0, not a naive + // even split of the table (which would starve it to ~192pt and force an + // unnecessary wrap of this 250pt-wide text). + auto cell_a1 = std::make_shared(); + auto text_a1 = std::make_shared("a long unwrapped line of text"); + cell_a1->set_content(text_a1); + auto cell_b1 = std::make_shared(); + cell_b1->set_content(std::make_shared("short")); + table->add_row({cell_a1, cell_b1}); + + table->accept(*processor()); + + EXPECT_TRUE(text_a1->wrapped_lines().empty()); + EXPECT_FLOAT_EQ(text_a1->edit_layout_box().measured_size.width, 250.0F); + } } From fe998fe43f2999cc8f31fa92e8227fdff21083cf Mon Sep 17 00:00:00 2001 From: cadons Date: Sun, 16 Aug 2026 19:30:26 +0200 Subject: [PATCH 2/2] test(DocraftLoomTable): simplify text metric expectations in tests --- .../loom/nodes/docraft_loom_table_test.cc | 76 +++++++++---------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_table_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_table_test.cc index ea0c7c9..5ec0d02 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_table_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_table_test.cc @@ -34,21 +34,38 @@ namespace docraft::test { return cell; } + // The width/height pair most tests below mock the text backend with -- kept as + // one call so a test only states the two numbers that actually matter to it, + // not the boilerplate EXPECT_CALL wiring around them. + void expect_uniform_text_metrics(float width, float height = 10.0F) + { + EXPECT_CALL(*text_backend_, measure_text_width(_, _, _)).WillRepeatedly(Return(width)); + EXPECT_CALL(*text_backend_, measure_text_height(_, _)).WillRepeatedly(Return(height)); + } + + // Runs Measure over `table`, then hands back a not-yet-accepted Layout processor + // for `page_width` -- callers that don't need anything special between the two + // passes just do `table->accept(prepare_layout(*table, W));`; callers that do + // (e.g. reset_cursor()) call that on the returned processor first. + loom::pipeline::DocraftLoomLayoutProcessor prepare_layout(loom::nodes::DocraftLoomTable& table, + float page_width) + { + table.accept(*measure_); + return loom::pipeline::DocraftLoomLayoutProcessor(page_width); + } + std::shared_ptr text_backend_; std::unique_ptr measure_; }; TEST_F(DocraftLoomTableTest, EqualWeightColumnsDistributeEvenly) { - EXPECT_CALL(*text_backend_, measure_text_width(_, _, _)).WillRepeatedly(Return(10.0F)); - EXPECT_CALL(*text_backend_, measure_text_height(_, _)).WillRepeatedly(Return(10.0F)); + expect_uniform_text_metrics(10.0F); auto table = std::make_shared(); table->set_padding(0.0F); // isolate column-width math from the table's own outer padding table->add_row({make_cell("a", true), make_cell("b", true)}); - table->accept(*measure_); - - loom::pipeline::DocraftLoomLayoutProcessor layout(200.0F); + auto layout = prepare_layout(*table, 200.0F); table->accept(layout); // available_width = 200 - 2*2.5 = 195, split evenly -> ~97.5 each (floored at natural 10) @@ -58,17 +75,14 @@ namespace docraft::test { TEST_F(DocraftLoomTableTest, ExplicitWidthIsRespectedAndRemainderRedistributed) { - EXPECT_CALL(*text_backend_, measure_text_width(_, _, _)).WillRepeatedly(Return(10.0F)); - EXPECT_CALL(*text_backend_, measure_text_height(_, _)).WillRepeatedly(Return(10.0F)); + expect_uniform_text_metrics(10.0F); auto table = std::make_shared(); table->set_padding(0.0F); // isolate column-width math from the table's own outer padding auto c0 = make_cell("a", true); c0->set_explicit_width(40.0F); table->add_row({c0, make_cell("b", true)}); - table->accept(*measure_); - - loom::pipeline::DocraftLoomLayoutProcessor layout(200.0F); + auto layout = prepare_layout(*table, 200.0F); table->accept(layout); EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame.size.width, 40.0F); @@ -92,18 +106,16 @@ namespace docraft::test { // shrunk back down along with everything else -- the floor is not an absolute // per-column guarantee once a rescale is triggered, only a preference that // holds when it doesn't need to fight the rescale. - EXPECT_CALL(*text_backend_, measure_text_width(_, _, _)).WillRepeatedly(Return(75.0F)); - EXPECT_CALL(*text_backend_, measure_text_height(_, _)).WillRepeatedly(Return(10.0F)); + expect_uniform_text_metrics(75.0F); auto table = std::make_shared(); table->set_padding(0.0F); // isolate column-width math from the table's own outer padding table->add_row({make_cell("a", true), make_cell("b", true)}); - table->accept(*measure_); // available_width = 200 - 2*2.5 = 195; weight share = 97.5 each, which already // exceeds the natural width of 75, so the floor never has to compete with the // rescale here -- both columns simply get their even weight-based share. - loom::pipeline::DocraftLoomLayoutProcessor layout(200.0F); + auto layout = prepare_layout(*table, 200.0F); table->accept(layout); EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame.size.width, 97.5F); @@ -126,9 +138,7 @@ namespace docraft::test { auto table = std::make_shared(); table->set_padding(0.0F); // isolate column-width math from the table's own outer padding table->add_row({make_cell("very long title text", true), make_cell("b", true)}); - table->accept(*measure_); - - loom::pipeline::DocraftLoomLayoutProcessor layout(20.0F); // deliberately too narrow + auto layout = prepare_layout(*table, 20.0F); // deliberately too narrow table->accept(layout); // Cell natural widths include the automatic content padding (2*2.5 = 5), so @@ -147,9 +157,7 @@ namespace docraft::test { auto table = std::make_shared(); table->add_row({make_cell("a"), make_cell("b")}); - table->accept(*measure_); - - loom::pipeline::DocraftLoomLayoutProcessor layout(200.0F); + auto layout = prepare_layout(*table, 200.0F); table->accept(layout); EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame.size.height, 35.0F); // 30 + 2*2.5 @@ -157,14 +165,11 @@ namespace docraft::test { TEST_F(DocraftLoomTableTest, LayoutDoesNotOverlapColumns) { - EXPECT_CALL(*text_backend_, measure_text_width(_, _, _)).WillRepeatedly(Return(10.0F)); - EXPECT_CALL(*text_backend_, measure_text_height(_, _)).WillRepeatedly(Return(10.0F)); + expect_uniform_text_metrics(10.0F); auto table = std::make_shared(); table->add_row({make_cell("a"), make_cell("b"), make_cell("c")}); - table->accept(*measure_); - - loom::pipeline::DocraftLoomLayoutProcessor layout(300.0F); + auto layout = prepare_layout(*table, 300.0F); table->accept(layout); const float col0_right = table->cell(0, 0)->layout_box().frame.position.x @@ -182,15 +187,12 @@ namespace docraft::test { // away from wherever the parent positioned this table -- not just inset cell // content within an unmoved border, which would leave the table sitting flush // against whatever precedes it. - EXPECT_CALL(*text_backend_, measure_text_width(_, _, _)).WillRepeatedly(Return(10.0F)); - EXPECT_CALL(*text_backend_, measure_text_height(_, _)).WillRepeatedly(Return(10.0F)); + expect_uniform_text_metrics(10.0F); auto table = std::make_shared(); table->set_padding(6.0F); table->add_row({make_cell("a"), make_cell("b")}); - table->accept(*measure_); - - loom::pipeline::DocraftLoomLayoutProcessor layout(200.0F); + auto layout = prepare_layout(*table, 200.0F); layout.reset_cursor(10.0F, 20.0F); table->accept(layout); @@ -262,14 +264,11 @@ namespace docraft::test { TEST_F(DocraftLoomTableTest, FrameSizeMatchesMeasuredSizeForTableAndCells) { - EXPECT_CALL(*text_backend_, measure_text_width(_, _, _)).WillRepeatedly(Return(10.0F)); - EXPECT_CALL(*text_backend_, measure_text_height(_, _)).WillRepeatedly(Return(10.0F)); + expect_uniform_text_metrics(10.0F); auto table = std::make_shared(); table->add_row({make_cell("a"), make_cell("b")}); - table->accept(*measure_); - - loom::pipeline::DocraftLoomLayoutProcessor layout(200.0F); + auto layout = prepare_layout(*table, 200.0F); table->accept(layout); EXPECT_GT(table->layout_box().frame.size.width, 0.0F); @@ -280,15 +279,12 @@ namespace docraft::test { TEST_F(DocraftLoomTableTest, RowTitleGridProducesCorrectLayout) { // "vertical"-style usage: column 0 holds row labels (is_title), column 1 holds values. - EXPECT_CALL(*text_backend_, measure_text_width(_, _, _)).WillRepeatedly(Return(10.0F)); - EXPECT_CALL(*text_backend_, measure_text_height(_, _)).WillRepeatedly(Return(10.0F)); + expect_uniform_text_metrics(10.0F); auto table = std::make_shared(); table->add_row({make_cell("Name", true), make_cell("Alice")}); table->add_row({make_cell("Age", true), make_cell("30")}); - table->accept(*measure_); - - loom::pipeline::DocraftLoomLayoutProcessor layout(200.0F); + auto layout = prepare_layout(*table, 200.0F); table->accept(layout); EXPECT_FLOAT_EQ(table->cell(1, 0)->layout_box().frame.position.y,